Init Repo
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- Show layout for authenticated routes -->
|
||||
<AppLayout v-if="showLayout">
|
||||
<template #detail-panel>
|
||||
<slot name="detail-panel" />
|
||||
</template>
|
||||
</AppLayout>
|
||||
|
||||
<!-- Show loading spinner while user data is being fetched -->
|
||||
<div v-else-if="isLoadingUser" class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p class="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show standalone pages for auth routes -->
|
||||
<router-view v-else />
|
||||
|
||||
<!-- Global Toast Container -->
|
||||
<Toaster />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Show layout only for authenticated routes (not login/register)
|
||||
const showLayout = computed(() => {
|
||||
const isAuthRoute = route.meta?.requiresGuest || route.name === 'Login' || route.name === 'Register'
|
||||
|
||||
// If it's an auth route (login/register), never show layout
|
||||
if (isAuthRoute) {
|
||||
return false
|
||||
}
|
||||
|
||||
// For protected routes, show layout only if fully authenticated (has both token and user)
|
||||
return authStore.isAuthenticated
|
||||
})
|
||||
|
||||
// Show loading state when we have a token but no user data yet
|
||||
const isLoadingUser = computed(() => {
|
||||
const isAuthRoute = route.meta?.requiresGuest || route.name === 'Login' || route.name === 'Register'
|
||||
|
||||
// Don't show loading for auth routes
|
||||
if (isAuthRoute) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Show loading if we have a token but no user data (and not currently loading)
|
||||
return !!authStore.accessToken && !authStore.user && !authStore.isLoading
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
# Components directory - will be populated in later tasks
|
||||
@@ -0,0 +1,286 @@
|
||||
<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.id)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${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}`)
|
||||
}
|
||||
|
||||
function getAvatarUrl(url: string | null | undefined, userId?: number): string {
|
||||
if (!url) return ''
|
||||
// If it's already a full URL, return it
|
||||
if (url.startsWith('http')) return url
|
||||
// Use direct static file serving
|
||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `/${cleanUrl}`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,209 @@
|
||||
<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>
|
||||
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle class="h-5 w-5" />
|
||||
Permanent Delete {{ isBulkOperation ? `${itemCount} Items` : itemName }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action is <strong>irreversible</strong> and will permanently remove all data from the database and file system.
|
||||
This is NOT a soft delete - the data cannot be recovered.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingInfo" class="flex items-center justify-center py-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span class="text-sm text-muted-foreground">Loading deletion information...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<Alert v-else-if="loadError" variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertTitle>Failed to load deletion information</AlertTitle>
|
||||
<AlertDescription>{{ loadError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Deletion Information -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Critical Warning -->
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<AlertTitle>⚠️ PERMANENT DELETION WARNING</AlertTitle>
|
||||
<AlertDescription class="space-y-2">
|
||||
<p class="font-semibold">This action will PERMANENTLY and IRREVERSIBLY:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Remove all database records</li>
|
||||
<li>Delete all associated files from the file system</li>
|
||||
<li>Remove all related tasks, submissions, attachments, notes, and reviews</li>
|
||||
<li>Erase all activity history</li>
|
||||
</ul>
|
||||
<p class="font-semibold mt-3 text-destructive-foreground">
|
||||
⚠️ THIS CANNOT BE UNDONE. THERE IS NO RECOVERY OPTION.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Bulk Operation Summary -->
|
||||
<div v-if="isBulkOperation" class="rounded-lg border bg-muted/20 p-4">
|
||||
<h3 class="font-medium mb-3">Items to be Permanently Deleted</h3>
|
||||
<div class="max-h-40 overflow-y-auto space-y-2">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
class="flex items-center justify-between p-2 bg-background rounded border"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ item.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ item.type === 'shot' ? 'Shot' : 'Asset' }} • {{ item.project_name }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{{ item.type }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single Item Details -->
|
||||
<div v-else-if="items.length === 1" class="rounded-lg border bg-muted/20 p-4">
|
||||
<h3 class="font-medium mb-3">Item Details</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Name:</span>
|
||||
<span class="font-medium">{{ items[0].name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Type:</span>
|
||||
<Badge variant="outline">{{ items[0].type }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Project:</span>
|
||||
<span class="font-medium">{{ items[0].project_name }}</span>
|
||||
</div>
|
||||
<div v-if="items[0].episode_name" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Episode:</span>
|
||||
<span class="font-medium">{{ items[0].episode_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Impact Summary -->
|
||||
<div class="rounded-lg border bg-destructive/10 p-4">
|
||||
<h3 class="font-medium mb-3 text-destructive">Deletion Impact</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<ListTodo class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ totalCounts.tasks }} task{{ totalCounts.tasks === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Upload class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ totalCounts.submissions }} submission{{ totalCounts.submissions === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Paperclip class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ totalCounts.attachments }} attachment{{ totalCounts.attachments === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<MessageSquare class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ totalCounts.notes }} note{{ totalCounts.notes === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ totalCounts.reviews }} review{{ totalCounts.reviews === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<HardDrive class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ formatFileSize(totalCounts.fileSize) }} files</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<Alert variant="default" class="border-orange-200 bg-orange-50">
|
||||
<Shield class="h-4 w-4 text-orange-600" />
|
||||
<AlertTitle class="text-orange-800">Security Confirmation Required</AlertTitle>
|
||||
<AlertDescription class="text-orange-700">
|
||||
To prevent accidental deletion, you must type the confirmation phrase exactly as shown below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Confirmation Input -->
|
||||
<div class="space-y-2">
|
||||
<Label for="confirm-input" class="text-base">
|
||||
Type <code class="bg-destructive/20 px-2 py-1 rounded text-sm font-mono font-bold">{{ confirmationPhrase }}</code> to confirm permanent deletion:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-input"
|
||||
v-model="confirmationText"
|
||||
placeholder="Type the confirmation phrase"
|
||||
class="font-mono"
|
||||
:class="{ 'border-destructive': confirmationText && !isConfirmed }"
|
||||
@paste.prevent
|
||||
/>
|
||||
<p v-if="confirmationText && !isConfirmed" class="text-xs text-destructive">
|
||||
Confirmation phrase does not match. Please type it exactly as shown.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Final Warning -->
|
||||
<Alert variant="destructive" class="border-2">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<AlertDescription class="font-semibold">
|
||||
By confirming this action, you acknowledge that all data will be permanently destroyed and cannot be recovered by any means.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleCancel"
|
||||
:disabled="isDeleting"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="handleDelete"
|
||||
:disabled="!isConfirmed || isDeleting || isLoadingInfo || !!loadError"
|
||||
>
|
||||
<Loader2 v-if="isDeleting" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||
{{ isDeleting ? 'Deleting...' : 'Permanently Delete' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
ListTodo,
|
||||
Upload,
|
||||
Paperclip,
|
||||
MessageSquare,
|
||||
HardDrive,
|
||||
Shield,
|
||||
Trash2
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/ui/alert'
|
||||
|
||||
interface DeletedItem {
|
||||
id: number
|
||||
name: string
|
||||
type: 'shot' | 'asset'
|
||||
project_name: string
|
||||
episode_name?: string
|
||||
task_count: number
|
||||
submission_count: number
|
||||
attachment_count: number
|
||||
note_count: number
|
||||
review_count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
items: DeletedItem[]
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'confirm-delete': [confirmationToken: string]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const confirmationText = ref('')
|
||||
const isLoadingInfo = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const isBulkOperation = computed(() => props.items.length > 1)
|
||||
|
||||
const itemCount = computed(() => props.items.length)
|
||||
|
||||
const itemName = computed(() => {
|
||||
if (props.items.length === 0) return ''
|
||||
return props.items[0].name
|
||||
})
|
||||
|
||||
const confirmationPhrase = computed(() => {
|
||||
if (isBulkOperation.value) {
|
||||
return `DELETE ${itemCount.value} ITEMS`
|
||||
}
|
||||
return `DELETE ${itemName.value}`
|
||||
})
|
||||
|
||||
const isConfirmed = computed(() => {
|
||||
return confirmationText.value === confirmationPhrase.value
|
||||
})
|
||||
|
||||
const totalCounts = computed(() => {
|
||||
return props.items.reduce(
|
||||
(acc, item) => ({
|
||||
tasks: acc.tasks + item.task_count,
|
||||
submissions: acc.submissions + item.submission_count,
|
||||
attachments: acc.attachments + item.attachment_count,
|
||||
notes: acc.notes + item.note_count,
|
||||
reviews: acc.reviews + item.review_count,
|
||||
fileSize: acc.fileSize + (item.submission_count + item.attachment_count) * 1024 * 1024 // Estimate
|
||||
}),
|
||||
{ tasks: 0, submissions: 0, attachments: 0, notes: 0, reviews: 0, fileSize: 0 }
|
||||
)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const generateConfirmationToken = (): string => {
|
||||
// Generate confirmation token that matches backend expectations
|
||||
if (isBulkOperation.value) {
|
||||
const shotCount = props.items.filter(item => item.type === 'shot').length
|
||||
const assetCount = props.items.filter(item => item.type === 'asset').length
|
||||
|
||||
if (shotCount > 0 && assetCount > 0) {
|
||||
// Mixed bulk operation - will be handled separately by the parent component
|
||||
return 'CONFIRM_MIXED_BULK_PERMANENT_DELETE'
|
||||
} else if (shotCount > 0) {
|
||||
return 'CONFIRM_BULK_SHOTS_PERMANENT_DELETE'
|
||||
} else {
|
||||
return 'CONFIRM_BULK_ASSETS_PERMANENT_DELETE'
|
||||
}
|
||||
} else {
|
||||
const item = props.items[0]
|
||||
if (item.type === 'shot') {
|
||||
return 'CONFIRM_SHOT_PERMANENT_DELETE'
|
||||
} else {
|
||||
return 'CONFIRM_ASSET_PERMANENT_DELETE'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!isConfirmed.value) return
|
||||
|
||||
const confirmationToken = generateConfirmationToken()
|
||||
emit('confirm-delete', confirmationToken)
|
||||
}
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
confirmationText.value = ''
|
||||
isLoadingInfo.value = false
|
||||
loadError.value = null
|
||||
} else {
|
||||
confirmationText.value = ''
|
||||
isLoadingInfo.value = false
|
||||
loadError.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,801 @@
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<!-- Main Content (Full Width) -->
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar - Sticky -->
|
||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<AssetTableToolbar
|
||||
v-if="customTaskTypesLoaded"
|
||||
:view-mode="viewMode"
|
||||
:category-filter="selectedCategory"
|
||||
:search="searchQuery"
|
||||
:column-visibility="columnVisibility"
|
||||
:categories="categories"
|
||||
:all-task-types="allTaskTypes"
|
||||
:project-id="projectId"
|
||||
:selected-asset="selectedAsset"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
:show-thumbnails="showThumbnails"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@update:category-filter="handleCategoryFilterChange"
|
||||
@update:search="searchQuery = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@update:show-thumbnails="handleThumbnailToggle"
|
||||
@task-status-filter-changed="handleTaskStatusFilter"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
@create-asset="showCreateDialog = true"
|
||||
/>
|
||||
<div v-else 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 toolbar...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 assets...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="loadAssets" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="
|
||||
filteredAssets.length === 0 && !searchQuery && !selectedCategory
|
||||
"
|
||||
class="text-center py-12"
|
||||
>
|
||||
<Package class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No assets yet</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
Create your first asset to get started
|
||||
</p>
|
||||
<Button @click="showCreateDialog = true">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create Asset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- No Results State -->
|
||||
<div v-else-if="filteredAssets.length === 0" class="text-center py-12">
|
||||
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No assets found</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
Try adjusting your search or filter criteria
|
||||
</p>
|
||||
<Button @click="clearFilters" variant="outline"> Clear Filters </Button>
|
||||
</div>
|
||||
|
||||
<!-- Assets Grid/List -->
|
||||
<div v-else>
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
v-if="viewMode === 'grid'"
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3"
|
||||
>
|
||||
<AssetCard
|
||||
v-for="asset in filteredAssets"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:show-thumbnail="showThumbnails"
|
||||
@select="selectAsset"
|
||||
@edit="editAsset"
|
||||
@delete="deleteAsset"
|
||||
@view-tasks="viewAssetTasks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table View -->
|
||||
<AssetsDataTable
|
||||
v-else-if="customTaskTypesLoaded"
|
||||
:columns="assetColumns"
|
||||
:data="filteredAssets"
|
||||
:sorting="sorting"
|
||||
:column-visibility="columnVisibility"
|
||||
:all-task-types="allTaskTypes"
|
||||
@update:sorting="sorting = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@update:rowSelection="handleRowSelectionChange"
|
||||
@row-click="handleRowClick"
|
||||
/>
|
||||
<div v-else 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 table...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Asset Dialog -->
|
||||
<Dialog v-model:open="showCreateDialog">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Asset</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new asset to the project. Assets can be characters, props,
|
||||
sets, or vehicles.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AssetForm
|
||||
:project-id="projectId"
|
||||
:is-loading="isCreating"
|
||||
@submit="handleCreateAsset"
|
||||
@cancel="showCreateDialog = false"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Edit Asset Dialog -->
|
||||
<Dialog v-model:open="showEditDialog">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Asset</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the asset information and settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AssetForm
|
||||
:project-id="projectId"
|
||||
:asset="selectedAsset || undefined"
|
||||
:is-loading="isUpdating"
|
||||
@submit="handleUpdateAsset"
|
||||
@cancel="showEditDialog = false"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog v-model:open="showDeleteDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Asset</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{{ selectedAsset?.name }}"? This
|
||||
action cannot be undone and will remove all associated tasks.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@click="handleDeleteAsset"
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete Asset
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<!-- Asset Detail Panel (Desktop) with slide animation -->
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-300 ease-out"
|
||||
enter-from-class="translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transition-transform duration-300 ease-in"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel && selectedAsset"
|
||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||
>
|
||||
<AssetDetailPanel
|
||||
:project-id="projectId"
|
||||
:asset-id="selectedAsset.id"
|
||||
@close="closeDetailPanel"
|
||||
@edit="editAsset"
|
||||
@delete="deleteAsset"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@create-note="handleCreateNote"
|
||||
@upload-reference="handleUploadReference"
|
||||
@publish-version="handlePublishVersion"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Asset Detail Panel (Mobile) -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<AssetDetailPanel
|
||||
v-if="selectedAsset"
|
||||
:project-id="projectId"
|
||||
:asset-id="selectedAsset.id"
|
||||
@close="closeDetailPanel"
|
||||
@edit="editAsset"
|
||||
@delete="deleteAsset"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@create-note="handleCreateNote"
|
||||
@upload-reference="handleUploadReference"
|
||||
@publish-version="handlePublishVersion"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Package,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Building,
|
||||
Car,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import AssetCard from "./AssetCard.vue";
|
||||
import AssetForm from "./AssetForm.vue";
|
||||
import AssetDetailPanel from "./AssetDetailPanel.vue";
|
||||
import AssetsDataTable from "./AssetsDataTable.vue";
|
||||
import AssetTableToolbar from "./AssetTableToolbar.vue";
|
||||
import { createAssetColumns, type AssetColumnMeta } from "./columns";
|
||||
import { useAssetsStore } from "@/stores/assets";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useTaskStatusesStore } from "@/stores/taskStatuses";
|
||||
import { useDetailPanel } from "@/composables/useDetailPanel";
|
||||
import {
|
||||
AssetCategory,
|
||||
TaskStatus,
|
||||
type Asset,
|
||||
type AssetCreate,
|
||||
type AssetUpdate,
|
||||
} from "@/services/asset";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
import type { SortingState, VisibilityState } from '@tanstack/vue-table';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Stores and composables
|
||||
const assetsStore = useAssetsStore();
|
||||
const authStore = useAuthStore();
|
||||
const taskStatusesStore = useTaskStatusesStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
// TanStack Table state
|
||||
const sorting = ref<SortingState>([])
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
const rowSelection = ref<Record<string, boolean>>({})
|
||||
|
||||
// Computed for selected count
|
||||
const selectedCount = computed(() => {
|
||||
return Object.keys(rowSelection.value).length
|
||||
})
|
||||
|
||||
const initializeColumnVisibility = () => {
|
||||
const stored = sessionStorage.getItem('assetBrowser.columnVisibility')
|
||||
if (stored) {
|
||||
try {
|
||||
const parsedVisibility = JSON.parse(stored)
|
||||
|
||||
// Ensure custom task types are included in stored visibility
|
||||
const updatedVisibility = { ...parsedVisibility }
|
||||
let hasChanges = false
|
||||
|
||||
for (const customType of customTaskTypes.value) {
|
||||
if (!(customType in updatedVisibility)) {
|
||||
updatedVisibility[customType] = true
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
columnVisibility.value = updatedVisibility
|
||||
|
||||
if (hasChanges) {
|
||||
sessionStorage.setItem('assetBrowser.columnVisibility', JSON.stringify(updatedVisibility))
|
||||
}
|
||||
} catch {
|
||||
// Fall back to defaults
|
||||
setDefaultColumnVisibility()
|
||||
}
|
||||
} else {
|
||||
// Default visible columns
|
||||
setDefaultColumnVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
const setDefaultColumnVisibility = () => {
|
||||
const defaultVisibility: Record<string, boolean> = {
|
||||
name: true,
|
||||
category: true,
|
||||
status: true,
|
||||
thumbnail: false,
|
||||
modeling: true,
|
||||
surfacing: true,
|
||||
rigging: true,
|
||||
description: true,
|
||||
updatedAt: true
|
||||
}
|
||||
|
||||
// Add custom task types to default visibility if they exist
|
||||
for (const customType of customTaskTypes.value) {
|
||||
defaultVisibility[customType] = true
|
||||
}
|
||||
|
||||
columnVisibility.value = defaultVisibility
|
||||
}
|
||||
|
||||
// Don't initialize immediately - wait for custom task types to load
|
||||
// initializeColumnVisibility()
|
||||
|
||||
// Detail panel composable
|
||||
const {
|
||||
isDetailPanelEnabled,
|
||||
selectedEntity: selectedAsset,
|
||||
showMobileDetail,
|
||||
showPanel,
|
||||
toggleDetailPanelEnabled,
|
||||
closeDetailPanel,
|
||||
selectEntity: selectAsset,
|
||||
handleRowClick: handleRowClickComposable
|
||||
} = useDetailPanel<Asset>({
|
||||
isDialogOpen: () => showCreateDialog.value || showEditDialog.value || showDeleteDialog.value,
|
||||
sessionStorageKey: 'assetBrowser.detailPanelEnabled'
|
||||
})
|
||||
|
||||
// Reactive state - default to list view to show task status
|
||||
const viewMode = ref<"grid" | "list">("list");
|
||||
const selectedCategory = ref<AssetCategory | "all">("all");
|
||||
const searchQuery = ref("");
|
||||
const showCreateDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
const showDeleteDialog = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const taskStatusFilter = ref('')
|
||||
|
||||
// Thumbnail display state - with session storage
|
||||
const showThumbnails = ref(
|
||||
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
|
||||
);
|
||||
|
||||
// Computed properties
|
||||
const assets = computed(() => assetsStore.assets);
|
||||
const isLoading = computed(() => assetsStore.isLoading);
|
||||
const error = computed(() => assetsStore.error);
|
||||
|
||||
const categories = [
|
||||
{ value: AssetCategory.CHARACTERS, label: "Characters", icon: Users },
|
||||
{ value: AssetCategory.PROPS, label: "Props", icon: Package },
|
||||
{ value: AssetCategory.SETS, label: "Sets", icon: Building },
|
||||
{ value: AssetCategory.VEHICLES, label: "Vehicles", icon: Car },
|
||||
];
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
let filtered = [...(assets.value || [])];
|
||||
|
||||
// Filter out soft deleted assets unless user is admin
|
||||
if (!authStore.isAdmin) {
|
||||
filtered = filtered.filter(asset => !asset.deleted_at);
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory.value && selectedCategory.value !== "all") {
|
||||
filtered = filtered.filter(
|
||||
(asset) => asset.category === selectedCategory.value
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase().trim();
|
||||
filtered = filtered.filter(
|
||||
(asset) =>
|
||||
asset.name.toLowerCase().includes(query) ||
|
||||
asset.description?.toLowerCase().includes(query) ||
|
||||
asset.category.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Custom task types from project
|
||||
const customTaskTypes = ref<string[]>([]);
|
||||
const customTaskTypesLoaded = ref(false);
|
||||
|
||||
// All available task types for assets (standard + custom)
|
||||
const allTaskTypes = computed(() => {
|
||||
return ["modeling", "surfacing", "rigging", ...customTaskTypes.value];
|
||||
});
|
||||
|
||||
// Asset columns for TanStack Table
|
||||
const assetColumns = computed(() => {
|
||||
const meta: AssetColumnMeta = {
|
||||
projectId: props.projectId,
|
||||
categories: categories,
|
||||
onEdit: editAsset,
|
||||
onDelete: deleteAsset,
|
||||
onViewTasks: viewAssetTasks,
|
||||
onTaskStatusUpdated: handleTaskStatusUpdate,
|
||||
onBulkTaskStatusChange: handleBulkTaskStatusChange,
|
||||
getSelectedCount: () => selectedCount.value,
|
||||
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
|
||||
}
|
||||
|
||||
return createAssetColumns(allTaskTypes.value, meta);
|
||||
})
|
||||
|
||||
const loadAssets = async () => {
|
||||
console.log("AssetBrowser - Loading assets for project:", props.projectId);
|
||||
console.log(
|
||||
"AssetBrowser - Current assets count before fetch:",
|
||||
assetsStore.assets.length
|
||||
);
|
||||
try {
|
||||
const options: any = {};
|
||||
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all') {
|
||||
options.category = selectedCategory.value;
|
||||
}
|
||||
|
||||
if (taskStatusFilter.value) {
|
||||
options.taskStatusFilter = taskStatusFilter.value;
|
||||
}
|
||||
|
||||
// Convert TanStack Table sorting to API format
|
||||
if (sorting.value.length > 0) {
|
||||
const sort = sorting.value[0]
|
||||
options.sortBy = sort.id;
|
||||
options.sortDirection = sort.desc ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
await assetsStore.fetchAssets(props.projectId, options);
|
||||
console.log(
|
||||
"AssetBrowser - Assets loaded successfully:",
|
||||
assetsStore.assets.length
|
||||
);
|
||||
console.log("AssetBrowser - Assets data:", assetsStore.assets);
|
||||
console.log("AssetBrowser - View mode:", viewMode.value);
|
||||
console.log("AssetBrowser - Visible columns:", columnVisibility.value);
|
||||
if (assetsStore.assets.length > 0) {
|
||||
console.log("AssetBrowser - First asset task status:", assetsStore.assets[0].task_status);
|
||||
console.log("AssetBrowser - First asset task details:", assetsStore.assets[0].task_details);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("AssetBrowser - Failed to load assets:", err);
|
||||
console.error("AssetBrowser - Error details:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// TanStack Table event handlers
|
||||
const handleColumnVisibilityChange = (visibility: VisibilityState) => {
|
||||
columnVisibility.value = visibility
|
||||
sessionStorage.setItem('assetBrowser.columnVisibility', JSON.stringify(visibility))
|
||||
}
|
||||
|
||||
const handleRowSelectionChange = (selection: Record<string, boolean>) => {
|
||||
rowSelection.value = selection
|
||||
}
|
||||
|
||||
const handleRowClick = (asset: Asset, event: MouseEvent) => {
|
||||
// Use the detail panel composable for row click handling
|
||||
handleRowClickComposable(asset, event)
|
||||
}
|
||||
|
||||
const handleCategoryFilterChange = (category: AssetCategory | 'all') => {
|
||||
selectedCategory.value = category
|
||||
}
|
||||
|
||||
const handleThumbnailToggle = (show: boolean) => {
|
||||
showThumbnails.value = show
|
||||
// Sync with column visibility
|
||||
columnVisibility.value = {
|
||||
...columnVisibility.value,
|
||||
thumbnail: show
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkTaskStatusChange = async (taskType: string, newStatus: TaskStatus) => {
|
||||
const selectedAssetIds = Object.keys(rowSelection.value).map(id => parseInt(id))
|
||||
|
||||
if (selectedAssetIds.length === 0) {
|
||||
toast({
|
||||
title: "No assets selected",
|
||||
description: "Please select assets to update their task status.",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the new bulk update method from the store
|
||||
await assetsStore.bulkUpdateTaskStatus(selectedAssetIds, taskType, newStatus)
|
||||
|
||||
toast({
|
||||
title: "Task status updated",
|
||||
description: `Updated ${formatTaskType(taskType)} status for ${selectedAssetIds.length} asset(s).`,
|
||||
})
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to update task status",
|
||||
description: err instanceof Error ? err.message : "An error occurred",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const editAsset = (asset: Asset) => {
|
||||
selectedAsset.value = asset;
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const deleteAsset = (asset: Asset) => {
|
||||
selectedAsset.value = asset;
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const viewAssetTasks = (asset: Asset) => {
|
||||
// TODO: Navigate to asset tasks view
|
||||
console.log("View tasks for asset:", asset.name);
|
||||
};
|
||||
|
||||
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
||||
try {
|
||||
isCreating.value = true;
|
||||
// When creating, we know it's AssetCreate, but TypeScript needs the union type
|
||||
await assetsStore.createAsset(props.projectId, assetData as AssetCreate);
|
||||
showCreateDialog.value = false;
|
||||
toast({
|
||||
title: "Asset created",
|
||||
description: `${(assetData as AssetCreate).name} has been created successfully.`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to create asset",
|
||||
description: err instanceof Error ? err.message : "An error occurred",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateAsset = async (assetData: AssetCreate | AssetUpdate) => {
|
||||
if (!selectedAsset.value) return;
|
||||
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
// When updating, we know it's AssetUpdate, but TypeScript needs the union type
|
||||
await assetsStore.updateAsset(selectedAsset.value.id, assetData as AssetUpdate);
|
||||
showEditDialog.value = false;
|
||||
selectedAsset.value = null;
|
||||
toast({
|
||||
title: "Asset updated",
|
||||
description: "Asset has been updated successfully.",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to update asset",
|
||||
description: err instanceof Error ? err.message : "An error occurred",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async () => {
|
||||
if (!selectedAsset.value) return;
|
||||
|
||||
try {
|
||||
await assetsStore.deleteAsset(selectedAsset.value.id);
|
||||
showDeleteDialog.value = false;
|
||||
selectedAsset.value = null;
|
||||
toast({
|
||||
title: "Asset deleted",
|
||||
description: "Asset has been deleted successfully.",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to delete asset",
|
||||
description: err instanceof Error ? err.message : "An error occurred",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskStatusUpdate = async (
|
||||
assetId: number,
|
||||
taskType: string,
|
||||
newStatus: string
|
||||
) => {
|
||||
try {
|
||||
// Update the asset in the store
|
||||
await assetsStore.updateTaskStatus(assetId, taskType, newStatus as TaskStatus);
|
||||
|
||||
toast({
|
||||
title: "Task status updated",
|
||||
description: `${formatTaskType(taskType)} status updated successfully.`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to update task status",
|
||||
description: err instanceof Error ? err.message : "An error occurred",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
selectedCategory.value = "all";
|
||||
searchQuery.value = "";
|
||||
taskStatusFilter.value = "";
|
||||
loadAssets();
|
||||
};
|
||||
|
||||
const handleTaskStatusFilter = (filter: string) => {
|
||||
taskStatusFilter.value = filter;
|
||||
loadAssets();
|
||||
};
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1);
|
||||
};
|
||||
|
||||
// Detail panel event handlers
|
||||
const handleCreateTask = () => {
|
||||
// TODO: Navigate to task creation for this asset
|
||||
console.log('Create task for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
|
||||
const handleSelectTask = (task: any) => {
|
||||
// TODO: Open task detail panel
|
||||
console.log('Select task:', task);
|
||||
};
|
||||
|
||||
const handleCreateNote = () => {
|
||||
// TODO: Open note creation dialog
|
||||
console.log('Create note for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
|
||||
const handleUploadReference = () => {
|
||||
// TODO: Open reference upload dialog
|
||||
console.log('Upload reference for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
|
||||
const handlePublishVersion = () => {
|
||||
// TODO: Open version publish dialog
|
||||
console.log('Publish version for asset:', selectedAsset.value?.name);
|
||||
};
|
||||
|
||||
// Load custom task types from project
|
||||
const loadCustomTaskTypes = async () => {
|
||||
if (!props.projectId) return;
|
||||
|
||||
try {
|
||||
const { projectService } = await import('@/services/project');
|
||||
const project = await projectService.getProject(props.projectId);
|
||||
|
||||
customTaskTypes.value = project.custom_asset_task_types || [];
|
||||
customTaskTypesLoaded.value = true;
|
||||
|
||||
// Initialize column visibility AFTER custom task types are loaded
|
||||
initializeColumnVisibility();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom task types:', error);
|
||||
customTaskTypesLoaded.value = true; // Mark as loaded even if failed
|
||||
// Initialize with defaults even if loading fails
|
||||
initializeColumnVisibility();
|
||||
}
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.projectId,
|
||||
async (newProjectId) => {
|
||||
if (newProjectId) {
|
||||
// Reset loaded flag when project changes
|
||||
customTaskTypesLoaded.value = false;
|
||||
|
||||
// Load custom task types first, then assets
|
||||
try {
|
||||
await loadCustomTaskTypes();
|
||||
} catch (err) {
|
||||
console.warn('Could not load custom task types for new project:', err);
|
||||
}
|
||||
loadAssets();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Watch for custom task types changes and reinitialize column visibility
|
||||
watch(customTaskTypes, (newCustomTypes) => {
|
||||
if (newCustomTypes.length > 0) {
|
||||
// Reinitialize column visibility to include new custom types
|
||||
initializeColumnVisibility();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// Clear selections when filters change
|
||||
watch([selectedCategory, searchQuery], () => {
|
||||
rowSelection.value = {};
|
||||
});
|
||||
|
||||
// Save thumbnail toggle preference to session storage
|
||||
watch(showThumbnails, (newValue) => {
|
||||
sessionStorage.setItem('assetBrowser.showThumbnails', newValue.toString());
|
||||
// Sync with column visibility
|
||||
columnVisibility.value = {
|
||||
...columnVisibility.value,
|
||||
thumbnail: newValue
|
||||
};
|
||||
});
|
||||
|
||||
// Sync column visibility with thumbnail toggle
|
||||
watch(() => columnVisibility.value.thumbnail, (newValue) => {
|
||||
showThumbnails.value = newValue;
|
||||
});
|
||||
|
||||
// Watch for filter changes and reload assets
|
||||
watch([selectedCategory, taskStatusFilter], () => {
|
||||
loadAssets();
|
||||
});
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
if (props.projectId) {
|
||||
// Load task statuses for bulk operations
|
||||
taskStatusesStore.fetchProjectStatuses(props.projectId).catch(err => {
|
||||
console.warn('Could not load task statuses:', err);
|
||||
});
|
||||
|
||||
// Load custom task types FIRST (blocking) to ensure columns are created correctly
|
||||
try {
|
||||
await loadCustomTaskTypes();
|
||||
} catch (err) {
|
||||
console.warn('Could not load custom task types:', err);
|
||||
// Continue without custom task types
|
||||
customTaskTypesLoaded.value = true;
|
||||
}
|
||||
|
||||
// Then load assets
|
||||
loadAssets();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<Card
|
||||
class="group hover:shadow-md transition-all duration-200 cursor-pointer border-border/50 hover:border-border"
|
||||
@dblclick="$emit('select', asset)"
|
||||
>
|
||||
<!-- Thumbnail Section -->
|
||||
<div v-if="showThumbnail" class="w-full h-32 bg-muted flex items-center justify-center border-b">
|
||||
<component
|
||||
:is="getCategoryIcon(asset.category)"
|
||||
class="h-12 w-12 text-muted-foreground/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardHeader class="pb-2 pt-3 px-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div v-if="!showThumbnail" class="flex-shrink-0">
|
||||
<component
|
||||
:is="getCategoryIcon(asset.category)"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-sm truncate">{{ asset.name }}</CardTitle>
|
||||
<p class="text-xs text-muted-foreground capitalize">
|
||||
{{ formatCategory(asset.category) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild @click.stop>
|
||||
<Button variant="ghost" size="sm" class="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreHorizontal class="h-3 w-3" />
|
||||
<span class="sr-only">Asset actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click.stop="$emit('edit', asset)">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit Asset
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click.stop="$emit('view-tasks', asset)">
|
||||
<ListTodo class="h-4 w-4 mr-2" />
|
||||
View Tasks
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click.stop="$emit('delete', asset)"
|
||||
class="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete Asset
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="pt-0 px-3 pb-3">
|
||||
<!-- Status Badge -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<Badge :variant="getStatusVariant(asset.status)" class="text-[10px] px-1.5 py-0">
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full mr-1"
|
||||
:class="getStatusColor(asset.status)"
|
||||
></div>
|
||||
{{ formatStatus(asset.status) }}
|
||||
</Badge>
|
||||
|
||||
<!-- Task Count -->
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ListTodo class="h-3 w-3" />
|
||||
<span>{{ asset.task_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Types and Status -->
|
||||
<div v-if="asset.task_details && asset.task_details.length > 0" class="mb-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge
|
||||
v-for="task in asset.task_details"
|
||||
:key="task.task_type"
|
||||
:variant="getTaskStatusVariant(task.status)"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
<div
|
||||
class="w-1 h-1 rounded-full mr-1"
|
||||
:class="getTaskStatusColor(task.status)"
|
||||
></div>
|
||||
{{ formatTaskType(task.task_type) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="asset.description"
|
||||
class="text-xs text-muted-foreground line-clamp-2 mb-2"
|
||||
>
|
||||
{{ asset.description }}
|
||||
</p>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
<span>{{ formatDate(asset.created_at) }}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Users, Package, Building, Car, MoreHorizontal, Edit, ListTodo, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { AssetCategory, AssetStatus, TaskStatus, type Asset } from '@/services/asset'
|
||||
|
||||
interface Props {
|
||||
asset: Asset
|
||||
showThumbnail?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', asset: Asset): void
|
||||
(e: 'edit', asset: Asset): void
|
||||
(e: 'delete', asset: Asset): void
|
||||
(e: 'view-tasks', asset: Asset): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showThumbnail: false
|
||||
})
|
||||
defineEmits<Emits>()
|
||||
|
||||
// Methods
|
||||
const getCategoryIcon = (category: AssetCategory) => {
|
||||
switch (category) {
|
||||
case AssetCategory.CHARACTERS:
|
||||
return Users
|
||||
case AssetCategory.PROPS:
|
||||
return Package
|
||||
case AssetCategory.SETS:
|
||||
return Building
|
||||
case AssetCategory.VEHICLES:
|
||||
return Car
|
||||
default:
|
||||
return Package
|
||||
}
|
||||
}
|
||||
|
||||
const formatCategory = (category: AssetCategory) => {
|
||||
return category.replace('_', ' ')
|
||||
}
|
||||
|
||||
const formatStatus = (status: AssetStatus) => {
|
||||
return status.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 formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
const getTaskStatusVariant = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case TaskStatus.NOT_STARTED:
|
||||
return 'secondary'
|
||||
case TaskStatus.IN_PROGRESS:
|
||||
return 'default'
|
||||
case TaskStatus.SUBMITTED:
|
||||
return 'default'
|
||||
case TaskStatus.APPROVED:
|
||||
return 'default'
|
||||
case TaskStatus.RETAKE:
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const getTaskStatusColor = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case TaskStatus.NOT_STARTED:
|
||||
return 'bg-gray-400'
|
||||
case TaskStatus.IN_PROGRESS:
|
||||
return 'bg-blue-500'
|
||||
case TaskStatus.SUBMITTED:
|
||||
return 'bg-purple-500'
|
||||
case TaskStatus.APPROVED:
|
||||
return 'bg-green-500'
|
||||
case TaskStatus.RETAKE:
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Asset Creation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the asset and tasks that will be created.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Asset Details -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium">Asset Details</h4>
|
||||
<div class="rounded-md bg-muted p-3 space-y-1">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Name:</span>
|
||||
<span class="font-medium">{{ assetData.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Category:</span>
|
||||
<span class="font-medium">{{ formatCategory(assetData.category) }}</span>
|
||||
</div>
|
||||
<div v-if="assetData.description" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Description:</span>
|
||||
<span class="font-medium">{{ assetData.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks to be Created -->
|
||||
<div v-if="assetData.create_default_tasks && taskList.length > 0" class="space-y-2">
|
||||
<h4 class="text-sm font-medium">Tasks to be Created ({{ taskList.length }})</h4>
|
||||
<div class="rounded-md bg-muted p-3">
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="taskType in taskList"
|
||||
:key="taskType"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-primary"></div>
|
||||
{{ assetData.name }} - {{ formatTaskType(taskType) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Tasks Message -->
|
||||
<div v-else-if="assetData.create_default_tasks && taskList.length === 0" class="space-y-2">
|
||||
<h4 class="text-sm font-medium text-muted-foreground">No Tasks Selected</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
The asset will be created without any default tasks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="loading"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="$emit('confirm')"
|
||||
:disabled="loading"
|
||||
>
|
||||
<div v-if="loading" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Creating...
|
||||
</div>
|
||||
<span v-else>Create Asset</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { AssetCategory, type AssetCreate } from '@/services/asset'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
assetData: AssetCreate
|
||||
taskList: string[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formatCategory = (category: AssetCategory) => {
|
||||
const categoryMap = {
|
||||
[AssetCategory.CHARACTERS]: 'Characters',
|
||||
[AssetCategory.PROPS]: 'Props',
|
||||
[AssetCategory.SETS]: 'Sets',
|
||||
[AssetCategory.VEHICLES]: 'Vehicles'
|
||||
}
|
||||
return categoryMap[category] || category
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<AlertTriangle class="h-5 w-5 text-destructive" />
|
||||
Soft Delete Asset: {{ assetName }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will mark the asset and all related data as deleted while preserving it for potential recovery.
|
||||
The data will be hidden from normal operations but can be restored by administrators.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingInfo" class="flex items-center justify-center py-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span class="text-sm text-muted-foreground">Loading deletion information...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="loadError" class="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<AlertCircle class="h-4 w-4 text-destructive" />
|
||||
<span class="font-medium text-destructive">Failed to load deletion information</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Deletion Information -->
|
||||
<div v-else-if="deletionInfo" class="space-y-4">
|
||||
<!-- Asset Info -->
|
||||
<div class="rounded-lg border bg-muted/10 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Package class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="font-medium">{{ deletionInfo.asset_name }}</span>
|
||||
<Badge variant="outline" class="text-xs">{{ formatCategory(deletionInfo.asset_category) }}</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ deletionInfo.project_name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Impact Summary -->
|
||||
<div class="rounded-lg border bg-muted/20 p-4">
|
||||
<h3 class="font-medium mb-3">Deletion Impact Summary</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<ListTodo class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.task_count }} task{{ deletionInfo.task_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Upload class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.submission_count }} submission{{ deletionInfo.submission_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Paperclip class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.attachment_count }} attachment{{ deletionInfo.attachment_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<MessageSquare class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.note_count }} note{{ deletionInfo.note_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.review_count }} review{{ deletionInfo.review_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<HardDrive class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ formatFileSize(deletionInfo.total_file_size) }} files</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Affected Users -->
|
||||
<div v-if="deletionInfo.affected_users.length > 0" class="rounded-lg border border-orange-200 bg-orange-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Users class="h-4 w-4 text-orange-600" />
|
||||
<span class="font-medium text-orange-800">
|
||||
{{ deletionInfo.affected_users.length }} user{{ deletionInfo.affected_users.length === 1 ? '' : 's' }} will be affected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-orange-700 mb-3">
|
||||
The following users have work associated with this asset that will be marked as deleted:
|
||||
</p>
|
||||
|
||||
<div class="max-h-40 overflow-y-auto space-y-2">
|
||||
<div
|
||||
v-for="user in deletionInfo.affected_users"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between p-2 bg-white rounded border"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ user.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ user.email }} • {{ user.role }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground text-right">
|
||||
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.submission_count > 0">{{ user.submission_count }} submission{{ user.submission_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.note_count > 0">{{ user.note_count }} note{{ user.note_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.last_activity_date" class="mt-1">
|
||||
Last active: {{ formatDate(user.last_activity_date) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No affected users -->
|
||||
<div v-else class="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||
<span class="text-sm text-green-800">No users will be affected by this deletion.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Preservation Notice -->
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Shield class="h-4 w-4 text-blue-600" />
|
||||
<span class="font-medium text-blue-800">Data Preservation</span>
|
||||
</div>
|
||||
<p class="text-sm text-blue-700">
|
||||
All data will be preserved in the database and can be recovered by administrators.
|
||||
Files will remain on the server unchanged. This is a soft deletion, not permanent removal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation input -->
|
||||
<div class="space-y-2">
|
||||
<Label for="confirm-input">
|
||||
Type <code class="bg-muted px-1 py-0.5 rounded text-sm">{{ assetName }}</code> to confirm soft deletion:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-input"
|
||||
v-model="confirmationText"
|
||||
placeholder="Enter asset name to confirm"
|
||||
class="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="$emit('update:open', false)">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="!isConfirmed || isDeleting || isLoadingInfo || !!loadError"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<Loader2 v-if="isDeleting" class="mr-2 h-4 w-4 animate-spin" />
|
||||
Soft Delete Asset
|
||||
<span v-if="deletionInfo && deletionInfo.task_count > 0">
|
||||
and {{ deletionInfo.task_count }} Task{{ deletionInfo.task_count === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
ListTodo,
|
||||
Upload,
|
||||
Paperclip,
|
||||
MessageSquare,
|
||||
HardDrive,
|
||||
Users,
|
||||
Shield,
|
||||
Package
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { assetService, type AssetDeletionInfo } from '@/services/asset'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
assetId: number
|
||||
assetName: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'confirm-delete': []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const confirmationText = ref('')
|
||||
const isDeleting = ref(false)
|
||||
const isLoadingInfo = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
const deletionInfo = ref<AssetDeletionInfo | null>(null)
|
||||
|
||||
const isConfirmed = computed(() => {
|
||||
return confirmationText.value === props.assetName
|
||||
})
|
||||
|
||||
// Load deletion info when dialog opens
|
||||
const loadDeletionInfo = async () => {
|
||||
if (!props.assetId) return
|
||||
|
||||
isLoadingInfo.value = true
|
||||
loadError.value = null
|
||||
|
||||
try {
|
||||
deletionInfo.value = await assetService.getAssetDeletionInfo(props.assetId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load deletion info:', error)
|
||||
loadError.value = error instanceof Error ? error.message : 'Failed to load deletion information'
|
||||
} finally {
|
||||
isLoadingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size for display
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Format category for display
|
||||
const formatCategory = (category: string): string => {
|
||||
return category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
confirmationText.value = ''
|
||||
isDeleting.value = false
|
||||
deletionInfo.value = null
|
||||
loadError.value = null
|
||||
loadDeletionInfo()
|
||||
} else {
|
||||
confirmationText.value = ''
|
||||
isDeleting.value = false
|
||||
deletionInfo.value = null
|
||||
loadError.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isConfirmed.value) return
|
||||
|
||||
isDeleting.value = true
|
||||
try {
|
||||
emit('confirm-delete')
|
||||
} catch (error) {
|
||||
console.error('Delete operation failed:', error)
|
||||
toast({
|
||||
title: 'Deletion failed',
|
||||
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Asset Name -->
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Asset Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="Enter asset name"
|
||||
:disabled="isLoading"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Asset Category -->
|
||||
<div class="space-y-2">
|
||||
<Label for="category">Category</Label>
|
||||
<Select v-model="formData.category" :disabled="isLoading" required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
:value="category.value"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="category.icon" class="h-4 w-4" />
|
||||
{{ category.label }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Asset Status -->
|
||||
<div class="space-y-2">
|
||||
<Label for="status">Status</Label>
|
||||
<Select v-model="formData.status" :disabled="isLoading">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="status in statuses"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="getStatusColor(status.value)"
|
||||
></div>
|
||||
{{ status.label }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
placeholder="Enter asset description (optional)"
|
||||
:disabled="isLoading"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Default Tasks Section (only for new assets) -->
|
||||
<div v-if="!isEdit" class="space-y-4 border-t pt-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="create-default-tasks"
|
||||
v-model:checked="formData.create_default_tasks"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<Label for="create-default-tasks" class="text-sm font-medium">
|
||||
Create default tasks for this asset
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Automatically create standard tasks based on the asset category
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Default Tasks Preview -->
|
||||
<div v-if="formData.create_default_tasks && formData.category" class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">Default Tasks Preview</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
The following tasks will be created for {{ getCategoryLabel(formData.category) }} assets:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Task Selection -->
|
||||
<div v-if="defaultTasks.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="taskType in defaultTasks"
|
||||
:key="taskType"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
:id="`task-${taskType}`"
|
||||
:checked="selectedTaskTypes.includes(taskType)"
|
||||
@update:checked="toggleTaskType(taskType)"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<Label :for="`task-${taskType}`" class="text-sm">
|
||||
{{ formatTaskType(taskType) }}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state for default tasks -->
|
||||
<div v-else-if="loadingDefaultTasks" class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Loading default tasks...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog Preview -->
|
||||
<div v-if="formData.create_default_tasks && selectedTaskTypes.length > 0" class="rounded-md bg-muted p-3">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium mb-1">Tasks to be created:</div>
|
||||
<ul class="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li v-for="taskType in selectedTaskTypes" :key="taskType">
|
||||
{{ formData.name }} - {{ formatTaskType(taskType) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isLoading || !isFormValid"
|
||||
>
|
||||
<div v-if="isLoading" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{{ isEdit ? 'Updating...' : 'Creating...' }}
|
||||
</div>
|
||||
<span v-else>{{ isEdit ? 'Update Asset' : 'Create Asset' }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<AssetCreationConfirmDialog
|
||||
v-if="pendingSubmissionData"
|
||||
v-model:open="showConfirmDialog"
|
||||
:asset-data="(pendingSubmissionData as AssetCreate)"
|
||||
:task-list="selectedTaskTypes"
|
||||
:loading="isLoading"
|
||||
@confirm="handleConfirmSubmission"
|
||||
@cancel="handleCancelSubmission"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { Users, Package, Building, Car } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { AssetCategory, AssetStatus, type Asset, type AssetCreate, type AssetUpdate, assetService } from '@/services/asset'
|
||||
import AssetCreationConfirmDialog from './AssetCreationConfirmDialog.vue'
|
||||
|
||||
interface Props {
|
||||
asset?: Asset
|
||||
isLoading?: boolean
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', data: AssetCreate | AssetUpdate): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Form data
|
||||
const formData = ref({
|
||||
name: '',
|
||||
category: '' as AssetCategory | '',
|
||||
status: AssetStatus.NOT_STARTED,
|
||||
description: '',
|
||||
create_default_tasks: true
|
||||
})
|
||||
|
||||
// Default tasks state
|
||||
const defaultTasks = ref<string[]>([])
|
||||
const selectedTaskTypes = ref<string[]>([])
|
||||
const loadingDefaultTasks = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const isEdit = computed(() => !!props.asset)
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return formData.value.name.trim() !== '' && formData.value.category !== ''
|
||||
})
|
||||
|
||||
// Category options with icons
|
||||
const categories = [
|
||||
{ value: AssetCategory.CHARACTERS, label: 'Characters', icon: Users },
|
||||
{ value: AssetCategory.PROPS, label: 'Props', icon: Package },
|
||||
{ value: AssetCategory.SETS, label: 'Sets', icon: Building },
|
||||
{ value: AssetCategory.VEHICLES, label: 'Vehicles', icon: Car }
|
||||
]
|
||||
|
||||
// Status options
|
||||
const statuses = [
|
||||
{ value: AssetStatus.NOT_STARTED, label: 'Not Started' },
|
||||
{ value: AssetStatus.IN_PROGRESS, label: 'In Progress' },
|
||||
{ value: AssetStatus.ON_HOLD, label: 'On Hold' },
|
||||
{ value: AssetStatus.COMPLETED, label: 'Completed' },
|
||||
{ value: AssetStatus.APPROVED, label: 'Approved' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
const getCategoryLabel = (category: AssetCategory) => {
|
||||
const categoryMap = {
|
||||
[AssetCategory.CHARACTERS]: 'Characters',
|
||||
[AssetCategory.PROPS]: 'Props',
|
||||
[AssetCategory.SETS]: 'Sets',
|
||||
[AssetCategory.VEHICLES]: 'Vehicles'
|
||||
}
|
||||
return categoryMap[category] || category
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
|
||||
const toggleTaskType = (taskType: string) => {
|
||||
const index = selectedTaskTypes.value.indexOf(taskType)
|
||||
if (index > -1) {
|
||||
selectedTaskTypes.value.splice(index, 1)
|
||||
} else {
|
||||
selectedTaskTypes.value.push(taskType)
|
||||
}
|
||||
}
|
||||
|
||||
const loadDefaultTasks = async (category: AssetCategory) => {
|
||||
if (!category) return
|
||||
|
||||
loadingDefaultTasks.value = true
|
||||
try {
|
||||
// Pass projectId to get custom task types if available
|
||||
const tasks = await assetService.getDefaultTasksForCategory(category, props.projectId)
|
||||
defaultTasks.value = tasks
|
||||
selectedTaskTypes.value = [...tasks] // Select all by default
|
||||
} catch (error) {
|
||||
console.error('Failed to load default tasks:', error)
|
||||
defaultTasks.value = []
|
||||
selectedTaskTypes.value = []
|
||||
} finally {
|
||||
loadingDefaultTasks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation dialog state
|
||||
const showConfirmDialog = ref(false)
|
||||
const pendingSubmissionData = ref<AssetCreate | AssetUpdate | null>(null)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
const data: AssetCreate | AssetUpdate = {
|
||||
name: formData.value.name.trim(),
|
||||
category: formData.value.category as AssetCategory,
|
||||
status: formData.value.status,
|
||||
description: formData.value.description.trim() || undefined
|
||||
}
|
||||
|
||||
// Add default task options for new assets
|
||||
if (!isEdit.value) {
|
||||
(data as AssetCreate).create_default_tasks = formData.value.create_default_tasks
|
||||
if (formData.value.create_default_tasks && selectedTaskTypes.value.length > 0) {
|
||||
(data as AssetCreate).selected_task_types = selectedTaskTypes.value
|
||||
}
|
||||
|
||||
// Show confirmation dialog for new assets with tasks
|
||||
if (formData.value.create_default_tasks && selectedTaskTypes.value.length > 0) {
|
||||
pendingSubmissionData.value = data
|
||||
showConfirmDialog.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
emit('submit', data)
|
||||
}
|
||||
|
||||
const handleConfirmSubmission = () => {
|
||||
if (pendingSubmissionData.value) {
|
||||
emit('submit', pendingSubmissionData.value)
|
||||
showConfirmDialog.value = false
|
||||
pendingSubmissionData.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelSubmission = () => {
|
||||
showConfirmDialog.value = false
|
||||
pendingSubmissionData.value = null
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
name: '',
|
||||
category: '' as AssetCategory | '',
|
||||
status: AssetStatus.NOT_STARTED,
|
||||
description: '',
|
||||
create_default_tasks: true
|
||||
}
|
||||
defaultTasks.value = []
|
||||
selectedTaskTypes.value = []
|
||||
}
|
||||
|
||||
// Watch for asset changes to populate form
|
||||
watch(
|
||||
() => props.asset,
|
||||
(asset) => {
|
||||
if (asset) {
|
||||
formData.value = {
|
||||
name: asset.name,
|
||||
category: asset.category,
|
||||
status: asset.status,
|
||||
description: asset.description || '',
|
||||
create_default_tasks: true
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for category changes to load default tasks
|
||||
watch(
|
||||
() => formData.value.category,
|
||||
(newCategory) => {
|
||||
if (newCategory && !isEdit.value) {
|
||||
loadDefaultTasks(newCategory as AssetCategory)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Expose reset method
|
||||
defineExpose({
|
||||
resetForm
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Notes History (Top) -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
|
||||
<p class="text-sm">No notes yet for this asset's tasks.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="border rounded-lg p-4 space-y-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<!-- Note Header -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium">{{ note.author_name }}</span>
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{{ formatTaskType(note.task_type) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ note.task_name }} • {{ formatDate(note.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note Content -->
|
||||
<p class="text-sm whitespace-pre-wrap">{{ note.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MessageSquarePlus } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const props = defineProps<{
|
||||
assetId: number
|
||||
notes: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
notesUpdated: []
|
||||
}>()
|
||||
|
||||
function formatTaskType(taskType: string): string {
|
||||
return taskType.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- References List -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div v-if="references.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Image class="h-10 w-10 mb-2 opacity-50" />
|
||||
<p class="text-sm">No reference files yet for this asset's tasks.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="reference in references"
|
||||
:key="reference.id"
|
||||
class="border rounded-lg overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- File Preview -->
|
||||
<div class="aspect-video bg-muted flex items-center justify-center relative">
|
||||
<img
|
||||
v-if="isImage(reference.file_path)"
|
||||
:src="getFileUrl(reference.file_path)"
|
||||
:alt="reference.file_name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<FileIcon class="h-12 w-12" />
|
||||
<span class="text-xs">{{ getFileExtension(reference.file_name) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Info -->
|
||||
<div class="p-3 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ reference.file_name }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{{ formatTaskType(reference.task_type) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ reference.task_name }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0 flex-shrink-0"
|
||||
@click="downloadFile(reference)"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Uploaded {{ formatDate(reference.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Image, FileIcon, Download } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<{
|
||||
assetId: number
|
||||
references: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
referencesUpdated: []
|
||||
}>()
|
||||
|
||||
function isImage(filePath: string): boolean {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
|
||||
return imageExtensions.some(ext => filePath.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
function getFileUrl(filePath: string): string {
|
||||
if (!filePath) return ''
|
||||
if (filePath.startsWith('http')) return filePath
|
||||
const cleanPath = filePath.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `http://localhost:8000/${cleanPath}`
|
||||
}
|
||||
|
||||
function getFileExtension(fileName: string): string {
|
||||
const parts = fileName.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE'
|
||||
}
|
||||
|
||||
function formatTaskType(taskType: string): string {
|
||||
return taskType.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function downloadFile(reference: any) {
|
||||
const url = getFileUrl(reference.file_path)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = reference.file_name
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Main Toolbar Row -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- Left Side - Filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'grid' }"
|
||||
@click="$emit('update:view-mode', 'grid')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<LayoutGrid class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'list' }"
|
||||
@click="$emit('update:view-mode', 'list')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<List class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Package class="mr-2 h-4 w-4" />
|
||||
Category
|
||||
<Badge
|
||||
v-if="categoryFilter !== 'all'"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search category..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No category found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="all"
|
||||
@select="$emit('update:category-filter', 'all')"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
categoryFilter === 'all'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>All Categories</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
:value="category.value"
|
||||
@select="$emit('update:category-filter', category.value)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
categoryFilter === category.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<component :is="category.icon" class="h-4 w-4 mr-2" />
|
||||
<span>{{ category.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Thumbnail Toggle Button -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="$emit('update:show-thumbnails', !showThumbnails)"
|
||||
class="h-8"
|
||||
>
|
||||
<ImageIcon v-if="!showThumbnails" class="h-4 w-4 mr-2" />
|
||||
<ImageOff v-else class="h-4 w-4 mr-2" />
|
||||
{{ showThumbnails ? 'Hide' : 'Show' }} Thumbnails
|
||||
</Button>
|
||||
|
||||
<!-- Task Status Filter (only for list view) -->
|
||||
<TaskStatusFilter
|
||||
v-if="viewMode === 'list'"
|
||||
:project-id="projectId"
|
||||
@filter-changed="$emit('task-status-filter-changed', $event)"
|
||||
/>
|
||||
|
||||
<!-- Column Visibility Control (only for list view) -->
|
||||
<Popover v-if="viewMode === 'list'">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Settings2 class="mr-2 h-4 w-4" />
|
||||
View
|
||||
<Badge
|
||||
v-if="hiddenColumnsCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ hiddenColumnsCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandSeparator />
|
||||
<template v-for="column in allColumns">
|
||||
<CommandItem
|
||||
v-if="column.type == 'default'"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</CommandGroup>
|
||||
<CommandGroup>
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Task Types
|
||||
</div>
|
||||
<CommandSeparator />
|
||||
<template v-for="column in allColumns">
|
||||
<CommandItem
|
||||
v-if="column.type == 'task'"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Task Columns Toggle Button (only for list view) -->
|
||||
<Button
|
||||
v-if="viewMode === 'list'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleAllTaskColumns"
|
||||
class="h-8"
|
||||
>
|
||||
<ListTodo v-if="!allTaskColumnsVisible" class="h-4 w-4 mr-2" />
|
||||
<ListX v-else class="h-4 w-4 mr-2" />
|
||||
{{ allTaskColumnsVisible ? 'Hide' : 'Show' }} Tasks
|
||||
</Button>
|
||||
|
||||
<!-- Detail Panel Enable/Disable Toggle Button (only for list view) -->
|
||||
<Button
|
||||
v-if="viewMode === 'list'"
|
||||
@click="$emit('toggle-detail-panel')"
|
||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
:class="[
|
||||
'h-8 w-8 p-0',
|
||||
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
|
||||
]"
|
||||
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
|
||||
>
|
||||
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<Button
|
||||
v-if="hasFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Right Side - Search and Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<!-- Search -->
|
||||
<div class="relative w-64">
|
||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
:model-value="search"
|
||||
@update:model-value="debouncedSearch"
|
||||
placeholder="Search assets..."
|
||||
class="pl-9 h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create Asset Button -->
|
||||
<Button @click="$emit('create-asset')" size="sm" class="h-8 w-8 p-0">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
|
||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import TaskStatusFilter from './TaskStatusFilter.vue'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import type { Asset, AssetCategory } from '@/services/asset'
|
||||
|
||||
interface CategoryOption {
|
||||
value: AssetCategory
|
||||
label: string
|
||||
icon: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
viewMode: 'grid' | 'list'
|
||||
categoryFilter: AssetCategory | 'all'
|
||||
search: string
|
||||
columnVisibility: VisibilityState
|
||||
categories: CategoryOption[]
|
||||
allTaskTypes: string[]
|
||||
projectId: number
|
||||
selectedAsset: Asset | null
|
||||
isDetailPanelEnabled: boolean
|
||||
showThumbnails: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:view-mode': [value: 'grid' | 'list']
|
||||
'update:category-filter': [value: AssetCategory | 'all']
|
||||
'update:search': [value: string]
|
||||
'update:column-visibility': [value: VisibilityState]
|
||||
'update:show-thumbnails': [value: boolean]
|
||||
'task-status-filter-changed': [value: string]
|
||||
'toggle-detail-panel': []
|
||||
'create-asset': []
|
||||
}>()
|
||||
|
||||
// Column definitions - computed to be reactive to allTaskTypes changes
|
||||
const allColumns = computed(() => [
|
||||
{ id: 'thumbnail', label: 'Thumbnail', type: 'default' },
|
||||
{ id: 'name', label: 'Asset Name', type: 'default' },
|
||||
{ id: 'category', label: 'Category', type: 'default' },
|
||||
{ id: 'status', label: 'Status', type: 'default' },
|
||||
{ id: 'description', label: 'Description', type: 'default' },
|
||||
{ id: 'updatedAt', label: 'Updated', type: 'default' },
|
||||
...props.allTaskTypes.map(taskType => ({
|
||||
id: taskType,
|
||||
label: taskType.charAt(0).toUpperCase() + taskType.slice(1),
|
||||
type: 'task'
|
||||
})),
|
||||
])
|
||||
|
||||
// Computed
|
||||
const hasFilters = computed(() => {
|
||||
return (
|
||||
props.categoryFilter !== 'all' ||
|
||||
props.search !== ''
|
||||
)
|
||||
})
|
||||
|
||||
const hiddenColumnsCount = computed(() => {
|
||||
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
|
||||
})
|
||||
|
||||
// Check if all task columns are visible
|
||||
const allTaskColumnsVisible = computed(() => {
|
||||
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
||||
return taskColumns.length > 0 && taskColumns.every(col => props.columnVisibility[col.id] !== false)
|
||||
})
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const debouncedSearch = (value: string | number) => {
|
||||
const searchValue = typeof value === 'string' ? value : String(value)
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
emit('update:search', searchValue)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Methods
|
||||
const toggleColumn = (columnId: string, value: any) => {
|
||||
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
|
||||
emit('update:column-visibility', newVisibility)
|
||||
}
|
||||
|
||||
const toggleAllTaskColumns = () => {
|
||||
const newVisibility = { ...props.columnVisibility }
|
||||
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
||||
|
||||
// If all task columns are visible, hide them; otherwise show them
|
||||
const shouldHide = allTaskColumnsVisible.value
|
||||
|
||||
taskColumns.forEach(col => {
|
||||
newVisibility[col.id] = !shouldHide
|
||||
})
|
||||
|
||||
emit('update:column-visibility', newVisibility)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
emit('update:category-filter', 'all')
|
||||
emit('update:search', '')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
v-show="header.column.getIsVisible()"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
|
||||
header.column.id === 'select' ? 'w-12' : '',
|
||||
header.column.id === 'actions' ? 'w-12' : '',
|
||||
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
|
||||
]"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
:class="{
|
||||
'bg-muted/30': row.getIsSelected(),
|
||||
'table-row-selectable': true,
|
||||
'selecting': isRangeSelecting
|
||||
}"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in row.getAllCells()"
|
||||
:key="cell.id"
|
||||
v-show="cell.column.getIsVisible()"
|
||||
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/vue-table'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { type Asset } from '@/services/asset'
|
||||
|
||||
interface Props {
|
||||
columns: ColumnDef<Asset>[]
|
||||
data: Asset[]
|
||||
sorting: SortingState
|
||||
columnVisibility: VisibilityState
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sorting': [sorting: SortingState]
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
'update:rowSelection': [selection: Record<string, boolean>]
|
||||
'row-click': [asset: Asset, event: MouseEvent]
|
||||
'selection-cleared': []
|
||||
}>()
|
||||
|
||||
// Track the last selected row index for range selection
|
||||
const lastSelectedIndex = ref<number | null>(null)
|
||||
const isRangeSelecting = ref(false)
|
||||
const rowSelection = ref<Record<string, boolean>>({})
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.data
|
||||
},
|
||||
get columns() {
|
||||
return props.columns
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
getRowId: (row) => String(row.id),
|
||||
onSortingChange: (updaterOrValue) => {
|
||||
const newSorting =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(props.sorting)
|
||||
: updaterOrValue
|
||||
emit('update:sorting', newSorting)
|
||||
},
|
||||
onColumnVisibilityChange: (updaterOrValue) => {
|
||||
const newVisibility =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(props.columnVisibility)
|
||||
: updaterOrValue
|
||||
emit('update:columnVisibility', newVisibility)
|
||||
},
|
||||
// Re-add the onRowSelectionChange callback but make it work with our custom logic
|
||||
onRowSelectionChange: (updaterOrValue) => {
|
||||
const newSelection =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(rowSelection.value)
|
||||
: updaterOrValue
|
||||
rowSelection.value = newSelection
|
||||
},
|
||||
state: {
|
||||
get sorting() {
|
||||
return props.sorting
|
||||
},
|
||||
get columnVisibility() {
|
||||
return props.columnVisibility
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const handleRowClick = (asset: Asset, event: MouseEvent, row: any) => {
|
||||
// If double-click handler will handle it, skip selection logic
|
||||
if (event.detail === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we clicked on an interactive element (simplified check)
|
||||
const target = event.target as HTMLElement
|
||||
if (target) {
|
||||
// Check if we clicked on a button, checkbox, or other interactive element
|
||||
const interactiveElement = target.closest('button, input, select, textarea, a[href], [role="button"], [role="menuitem"]')
|
||||
if (interactiveElement) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle selection based on modifier keys
|
||||
handleRowSelection(row, event)
|
||||
emit('row-click', asset, event)
|
||||
}
|
||||
|
||||
const handleRowSelection = (row: any, event: MouseEvent) => {
|
||||
const currentIndex = row.index
|
||||
const allRows = table.getRowModel().rows
|
||||
const assetId = String(row.id)
|
||||
|
||||
if (event.shiftKey && lastSelectedIndex.value !== null) {
|
||||
// Prevent text selection when shift-clicking
|
||||
event.preventDefault()
|
||||
|
||||
// Range selection
|
||||
const startIndex = Math.min(lastSelectedIndex.value, currentIndex)
|
||||
const endIndex = Math.max(lastSelectedIndex.value, currentIndex)
|
||||
|
||||
// Create new selection object
|
||||
const newSelection: Record<string, boolean> = {}
|
||||
|
||||
// Select all rows in the range
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (allRows[i]) {
|
||||
newSelection[allRows[i].id] = true
|
||||
}
|
||||
}
|
||||
|
||||
rowSelection.value = newSelection
|
||||
lastSelectedIndex.value = currentIndex
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl/Cmd + Click: Toggle individual selection (additional selection)
|
||||
const newSelection: Record<string, boolean> = { ...rowSelection.value }
|
||||
|
||||
if (newSelection[assetId]) {
|
||||
// Row is selected, deselect it
|
||||
delete newSelection[assetId]
|
||||
} else {
|
||||
// Row is not selected, select it
|
||||
newSelection[assetId] = true
|
||||
}
|
||||
|
||||
rowSelection.value = newSelection
|
||||
lastSelectedIndex.value = currentIndex
|
||||
} else {
|
||||
// Default: Single selection (clear others and select this one)
|
||||
// This applies even if the row is already selected - it becomes the only selection
|
||||
rowSelection.value = { [assetId]: true }
|
||||
lastSelectedIndex.value = currentIndex
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
// Detect if this is a range selection operation
|
||||
if (event.shiftKey) {
|
||||
isRangeSelecting.value = true
|
||||
// Prevent text selection immediately
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// Reset range selecting state
|
||||
isRangeSelecting.value = false
|
||||
}
|
||||
|
||||
// Watch rowSelection changes and emit selection-change events
|
||||
watch(
|
||||
rowSelection,
|
||||
(newSelection) => {
|
||||
emit('update:rowSelection', newSelection)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Prevent text selection during range selection */
|
||||
.table-row-selectable.selecting {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Prevent text selection on shift key operations */
|
||||
.table-row-selectable {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Allow text selection for specific elements that should be selectable */
|
||||
.table-row-selectable input,
|
||||
.table-row-selectable textarea,
|
||||
.table-row-selectable [contenteditable] {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Global Task Columns Toggle Button -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleAllTaskColumns"
|
||||
class="h-9"
|
||||
>
|
||||
<ListTodo v-if="!allTaskColumnsVisible" class="h-4 w-4 mr-2" />
|
||||
<ListX v-else class="h-4 w-4 mr-2" />
|
||||
{{ allTaskColumnsVisible ? 'Hide' : 'Show' }} Tasks
|
||||
</Button>
|
||||
|
||||
<!-- Column Visibility Dropdown -->
|
||||
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Toggle columns">
|
||||
<div class="flex items-center gap-2">
|
||||
<Columns class="h-4 w-4" />
|
||||
<span>Columns</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="toggle">Toggle Columns</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Basic Columns</SelectLabel>
|
||||
<SelectItem value="thumbnail" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.thumbnail"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('thumbnail', val)"
|
||||
/>
|
||||
<span>Thumbnail</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="name" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.name"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('name', val)"
|
||||
/>
|
||||
<span>Name</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="category" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.category"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('category', val)"
|
||||
/>
|
||||
<span>Category</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="status" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.status"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('status', val)"
|
||||
/>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Task Status Columns</SelectLabel>
|
||||
<!-- Standard Task Types -->
|
||||
<SelectItem value="modeling" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.modeling"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('modeling', val)"
|
||||
/>
|
||||
<span>Modeling</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="surfacing" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.surfacing"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('surfacing', val)"
|
||||
/>
|
||||
<span>Surfacing</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="rigging" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.rigging"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('rigging', val)"
|
||||
/>
|
||||
<span>Rigging</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<!-- Custom Task Types -->
|
||||
<SelectItem
|
||||
v-for="customType in customTaskTypes"
|
||||
:key="customType"
|
||||
:value="customType"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns[customType]"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn(customType, val)"
|
||||
/>
|
||||
<span>{{ formatTaskType(customType) }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Other Columns</SelectLabel>
|
||||
<SelectItem value="description" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.description"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('description', val)"
|
||||
/>
|
||||
<span>Description</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="updatedAt" @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="visibleColumns.updatedAt"
|
||||
@update:checked="(val: boolean | 'indeterminate') => updateColumn('updatedAt', val)"
|
||||
/>
|
||||
<span>Updated</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Columns, ListTodo, ListX } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface Props {
|
||||
visibleColumns: Record<string, boolean>;
|
||||
projectId?: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:visibleColumns", columns: Record<string, boolean>): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const selectedColumn = ref('toggle')
|
||||
const customTaskTypes = ref<string[]>([])
|
||||
const savedTaskColumnStates = ref<Record<string, boolean>>({})
|
||||
|
||||
// Standard task types
|
||||
const standardTaskTypes = ['modeling', 'surfacing', 'rigging']
|
||||
|
||||
// All task types (standard + custom)
|
||||
const allTaskTypes = computed(() => [...standardTaskTypes, ...customTaskTypes.value])
|
||||
|
||||
// Check if all task columns are visible
|
||||
const allTaskColumnsVisible = computed(() => {
|
||||
return allTaskTypes.value.every(taskType => props.visibleColumns[taskType])
|
||||
})
|
||||
|
||||
// Load custom task types from project
|
||||
const loadCustomTaskTypes = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
const { projectService } = await import('@/services/project')
|
||||
const project = await projectService.getProject(props.projectId)
|
||||
customTaskTypes.value = project.custom_asset_task_types || []
|
||||
|
||||
// Initialize visibility for custom task types if not already set
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
let hasChanges = false
|
||||
|
||||
for (const customType of customTaskTypes.value) {
|
||||
if (!(customType in newColumns)) {
|
||||
newColumns[customType] = true // Show custom types by default
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
emit("update:visibleColumns", newColumns)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load custom task types:', error)
|
||||
// Continue without custom task types
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle all task columns show/hide
|
||||
const toggleAllTaskColumns = () => {
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
|
||||
if (allTaskColumnsVisible.value) {
|
||||
// Hide all task columns but save their states
|
||||
savedTaskColumnStates.value = {}
|
||||
for (const taskType of allTaskTypes.value) {
|
||||
savedTaskColumnStates.value[taskType] = newColumns[taskType]
|
||||
newColumns[taskType] = false
|
||||
}
|
||||
} else {
|
||||
// Restore saved states or show all
|
||||
for (const taskType of allTaskTypes.value) {
|
||||
if (taskType in savedTaskColumnStates.value) {
|
||||
newColumns[taskType] = savedTaskColumnStates.value[taskType]
|
||||
} else {
|
||||
newColumns[taskType] = true
|
||||
}
|
||||
}
|
||||
savedTaskColumnStates.value = {}
|
||||
}
|
||||
|
||||
emit("update:visibleColumns", newColumns)
|
||||
}
|
||||
|
||||
const handleColumnToggle = () => {
|
||||
// Reset selection after interaction
|
||||
selectedColumn.value = 'toggle'
|
||||
}
|
||||
|
||||
const updateColumn = (column: string, checked: boolean | 'indeterminate') => {
|
||||
const newColumns = { ...props.visibleColumns }
|
||||
newColumns[column] = checked === true
|
||||
emit('update:visibleColumns', newColumns)
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomTaskTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="relative"
|
||||
>
|
||||
<Select
|
||||
:model-value="currentStatusId"
|
||||
@update:model-value="handleStatusChange"
|
||||
:disabled="isUpdating || isLoadingStatuses"
|
||||
|
||||
>
|
||||
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
|
||||
:style="{ backgroundColor: currentStatusObject.color }"
|
||||
>
|
||||
<SelectValue
|
||||
:model-value="currentStatusId"
|
||||
>
|
||||
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
|
||||
<!-- console.log(currentStatusObject) -->
|
||||
|
||||
<!-- currentStatusObject -->
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="statusOption in allStatusOptions"
|
||||
:key="statusOption.id"
|
||||
:value="statusOption.id"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Color indicator -->
|
||||
<div
|
||||
v-if="statusOption.color"
|
||||
class="w-3 h-3 rounded-full border border-border"
|
||||
:style="{ backgroundColor: statusOption.color }"
|
||||
/>
|
||||
<TaskStatusBadge :status="statusOption" compact />
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div
|
||||
v-if="isUpdating || isLoadingStatuses"
|
||||
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { taskService } from '@/services/task'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
is_system?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
assetId: number
|
||||
taskType: string
|
||||
status: TaskStatus | string
|
||||
taskId?: number | null
|
||||
projectId: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'status-updated', assetId: number, taskType: string, newStatus: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
|
||||
// Get loading state from store
|
||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||
|
||||
// Get all status options from store
|
||||
const allStatusOptions = computed(() => taskStatusesStore.getAllStatusOptions(props.projectId))
|
||||
|
||||
// Get current status ID (handle both TaskStatus enum and custom status strings)
|
||||
const currentStatusId = computed(() => {
|
||||
if (typeof props.status === 'string') {
|
||||
return props.status
|
||||
}
|
||||
return props.status as string
|
||||
})
|
||||
|
||||
// Get current status object for display using store
|
||||
const currentStatusObject = computed((): StatusOption => {
|
||||
const statusFromStore = taskStatusesStore.getStatusById(props.projectId, currentStatusId.value)
|
||||
if (statusFromStore) {
|
||||
return {
|
||||
id: statusFromStore.id,
|
||||
name: statusFromStore.name,
|
||||
color: statusFromStore.color,
|
||||
is_system: 'is_system' in statusFromStore ? statusFromStore.is_system : false
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to current status as-is
|
||||
return {
|
||||
id: currentStatusId.value,
|
||||
name: formatStatusName(currentStatusId.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Format status name for display
|
||||
const formatStatusName = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'not_started':
|
||||
return 'Not Started'
|
||||
case 'in_progress':
|
||||
return 'In Progress'
|
||||
case 'submitted':
|
||||
return 'Submitted'
|
||||
case 'approved':
|
||||
return 'Approved'
|
||||
case 'retake':
|
||||
return 'Retake'
|
||||
default:
|
||||
// Convert snake_case to Title Case
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch custom statuses for the project using store
|
||||
const fetchStatuses = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatusId: any) => {
|
||||
if (!newStatusId || newStatusId === currentStatusId.value) return
|
||||
|
||||
const statusId = newStatusId as string
|
||||
|
||||
isUpdating.value = true
|
||||
|
||||
try {
|
||||
let taskId = props.taskId
|
||||
|
||||
// If no task exists, create one first
|
||||
if (!taskId) {
|
||||
const newTask = await taskService.createAssetTask(props.assetId, props.taskType)
|
||||
taskId = newTask.task_id
|
||||
}
|
||||
|
||||
// Update the task status
|
||||
if (taskId) {
|
||||
await taskService.updateTaskStatus(taskId, statusId as TaskStatus)
|
||||
emit('status-updated', props.assetId, props.taskType, statusId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error)
|
||||
// Revert the status change by emitting the original status
|
||||
// This will cause the parent component to refresh the data
|
||||
emit('status-updated', props.assetId, props.taskType, currentStatusId.value)
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch statuses on mount
|
||||
onMounted(() => {
|
||||
fetchStatuses()
|
||||
})
|
||||
|
||||
// Refetch statuses when projectId changes
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<Badge :variant="getStatusVariant(status)" class="w-[100px] text-xs text-middle">
|
||||
{{ formatStatus(status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
|
||||
interface Props {
|
||||
status: TaskStatus
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const getStatusVariant = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case TaskStatus.NOT_STARTED:
|
||||
return 'secondary'
|
||||
case TaskStatus.IN_PROGRESS:
|
||||
return 'default'
|
||||
case TaskStatus.SUBMITTED:
|
||||
return 'outline'
|
||||
case TaskStatus.APPROVED:
|
||||
return 'default'
|
||||
case TaskStatus.RETAKE:
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: TaskStatus) => {
|
||||
switch (status) {
|
||||
case TaskStatus.NOT_STARTED:
|
||||
return 'Not Started'
|
||||
case TaskStatus.IN_PROGRESS:
|
||||
return 'In Progress'
|
||||
case TaskStatus.SUBMITTED:
|
||||
return 'Submitted'
|
||||
case TaskStatus.APPROVED:
|
||||
return 'Approved'
|
||||
case TaskStatus.RETAKE:
|
||||
return 'Retake'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="selectedFilter" @update:model-value="handleFilterChange">
|
||||
<SelectTrigger class="w-[200px]">
|
||||
<SelectValue placeholder="Filter by task status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Tasks</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Modeling</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="status in allStatuses"
|
||||
:key="`modeling:${status.id}`"
|
||||
:value="`modeling:${status.id}`"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<TaskStatusBadge :status="status.id as any" />
|
||||
<span>Modeling - {{ status.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Surfacing</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="status in allStatuses"
|
||||
:key="`surfacing:${status.id}`"
|
||||
:value="`surfacing:${status.id}`"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<TaskStatusBadge :status="status as any" />
|
||||
<span>Surfacing - {{ status.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Rigging</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="status in allStatuses"
|
||||
:key="`rigging:${status.id}`"
|
||||
:value="`rigging:${status.id}`"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<TaskStatusBadge :status="status as any" />
|
||||
<span>Rigging - {{ status.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
v-if="selectedFilter && selectedFilter !== 'all'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearFilter"
|
||||
class="h-8 px-2"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
|
||||
interface Props {
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'filter-changed', filter: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
const selectedFilter = ref('all')
|
||||
|
||||
// Get loading state from store
|
||||
const isLoading = computed(() => {
|
||||
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
|
||||
})
|
||||
|
||||
// System status options (fallback if no project ID)
|
||||
const defaultStatusOptions = [
|
||||
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
|
||||
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
|
||||
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
|
||||
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
|
||||
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
|
||||
]
|
||||
|
||||
// Combine system and custom statuses
|
||||
const allStatuses = computed(() => {
|
||||
if (!props.projectId) {
|
||||
return defaultStatusOptions
|
||||
}
|
||||
|
||||
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
|
||||
if (!statusData) {
|
||||
return defaultStatusOptions
|
||||
}
|
||||
|
||||
// Convert system statuses to the format expected by TaskStatusBadge
|
||||
const systemStatusList = statusData.system_statuses.map(status => ({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
is_system: status.is_system
|
||||
}))
|
||||
|
||||
// Convert custom statuses to the format expected by TaskStatusBadge
|
||||
const customStatusList = statusData.statuses.map(status => ({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
is_system: false
|
||||
}))
|
||||
|
||||
return [...systemStatusList, ...customStatusList]
|
||||
})
|
||||
|
||||
// Load custom statuses when component mounts or projectId changes
|
||||
const loadStatuses = async () => {
|
||||
if (!props.projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
watch(() => props.projectId, () => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
const handleFilterChange = (filter: any) => {
|
||||
if (!filter) return
|
||||
const filterStr = String(filter)
|
||||
selectedFilter.value = filterStr
|
||||
// Convert "all" to empty string for the API
|
||||
const apiFilter = filterStr === 'all' ? '' : filterStr
|
||||
emit('filter-changed', apiFilter)
|
||||
}
|
||||
|
||||
const clearFilter = () => {
|
||||
selectedFilter.value = 'all'
|
||||
emit('filter-changed', '')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:checked="showTaskStatus"
|
||||
@update:checked="$emit('update:showTaskStatus', $event)"
|
||||
id="task-status-toggle"
|
||||
/>
|
||||
<Label for="task-status-toggle" class="text-sm font-medium">
|
||||
Show Task Status
|
||||
</Label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface Props {
|
||||
showTaskStatus: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:showTaskStatus", value: boolean): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
defineEmits<Emits>();
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:checked="showThumbnails"
|
||||
@update:checked="$emit('update:showThumbnails', $event)"
|
||||
id="thumbnail-toggle"
|
||||
/>
|
||||
<Label for="thumbnail-toggle" class="text-sm font-medium">
|
||||
Show Thumbnails
|
||||
</Label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface Props {
|
||||
showThumbnails: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:showThumbnails", value: boolean): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
defineEmits<Emits>();
|
||||
</script>
|
||||
@@ -0,0 +1,421 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h, ref } from 'vue'
|
||||
import { Users, Car, Building, Package, MoreHorizontal, Edit, ListTodo, Trash2, ArrowUpDown, ArrowUp, ArrowDown, ChevronDown } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
import EditableTaskStatus from './EditableTaskStatus.vue'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { type Asset, AssetCategory, AssetStatus, TaskStatus } from '@/services/asset'
|
||||
|
||||
// Helper function to get the appropriate sort icon
|
||||
const getSortIcon = (sortDirection: false | 'asc' | 'desc') => {
|
||||
if (sortDirection === 'asc') {
|
||||
return h(ArrowDown, { class: 'ml-2 h-4 w-4' })
|
||||
} else if (sortDirection === 'desc') {
|
||||
return h(ArrowUp, { class: 'ml-2 h-4 w-4' })
|
||||
} else {
|
||||
return h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get category icon
|
||||
const getCategoryIcon = (category: AssetCategory) => {
|
||||
switch (category) {
|
||||
case AssetCategory.CHARACTERS:
|
||||
return Users
|
||||
case AssetCategory.PROPS:
|
||||
return Package
|
||||
case AssetCategory.SETS:
|
||||
return Building
|
||||
case AssetCategory.VEHICLES:
|
||||
return Car
|
||||
default:
|
||||
return Package
|
||||
}
|
||||
}
|
||||
|
||||
export interface AssetColumnMeta {
|
||||
projectId: number
|
||||
categories: Array<{ value: string; label: string; icon: any }>
|
||||
onEdit: (asset: Asset) => void
|
||||
onDelete: (asset: Asset) => void
|
||||
onViewTasks: (asset: Asset) => void
|
||||
onTaskStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => void
|
||||
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
|
||||
getSelectedCount?: () => number
|
||||
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
|
||||
}
|
||||
|
||||
export const createAssetColumns = (
|
||||
allTaskTypes: string[],
|
||||
meta: AssetColumnMeta
|
||||
): ColumnDef<Asset>[] => {
|
||||
const columns: ColumnDef<Asset>[] = [
|
||||
// Select column
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(value === true),
|
||||
ariaLabel: 'Select all',
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(value === true),
|
||||
ariaLabel: 'Select row',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
}),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
// Thumbnail column
|
||||
{
|
||||
id: 'thumbnail',
|
||||
header: 'Thumbnail',
|
||||
cell: () => {
|
||||
return h('div', { class: 'w-20 h-11 bg-muted flex items-center justify-center' }, [
|
||||
h(Package, { class: 'h-6 w-6 text-muted-foreground' }),
|
||||
])
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Asset Name column
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
['Asset Name', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
const CategoryIcon = getCategoryIcon(asset.category)
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(CategoryIcon, { class: 'h-4 w-4 text-muted-foreground flex-shrink-0' }),
|
||||
h('span', { class: 'font-medium' }, asset.name),
|
||||
])
|
||||
},
|
||||
},
|
||||
// Category column
|
||||
{
|
||||
accessorKey: 'category',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
['Category', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
const categoryInfo = meta.categories.find(c => c.value === asset.category)
|
||||
const label = categoryInfo ? categoryInfo.label : formatCategory(asset.category)
|
||||
return h(Badge, { variant: 'outline', class: 'text-xs' }, () => label)
|
||||
},
|
||||
},
|
||||
// Status column
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
['Status', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
const variant = getStatusVariant(asset.status)
|
||||
const label = formatStatus(asset.status)
|
||||
return h(Badge, { variant, class: 'text-xs' }, () => label)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Add task status columns dynamically
|
||||
allTaskTypes.forEach((taskType) => {
|
||||
// Create ref for popover state for each task type
|
||||
const isPopoverOpen = ref(false)
|
||||
|
||||
columns.push({
|
||||
accessorKey: `task_status.${taskType}`,
|
||||
id: taskType,
|
||||
header: ({ column }) => {
|
||||
const selectedCount = meta.getSelectedCount?.() || 0
|
||||
|
||||
if (selectedCount > 0) {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
[formatTaskType(taskType), getSortIcon(column.getIsSorted())]
|
||||
),
|
||||
h('div', { onClick: (e: Event) => e.stopPropagation() }, [
|
||||
h(Popover, {
|
||||
open: isPopoverOpen.value,
|
||||
'onUpdate:open': (value: boolean) => { isPopoverOpen.value = value }
|
||||
}, {
|
||||
default: () => [
|
||||
h(PopoverTrigger, {}, {
|
||||
default: () => h(
|
||||
Button,
|
||||
{
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
class: 'h-6 w-6 p-0',
|
||||
},
|
||||
() => h(ChevronDown, { class: 'h-3 w-3' })
|
||||
),
|
||||
}),
|
||||
h(PopoverContent, { class: 'w-48 p-2', align: 'start' }, {
|
||||
default: () => {
|
||||
// Get task statuses from meta
|
||||
const allStatusOptions = meta.getAllStatusOptions?.() || []
|
||||
|
||||
return h('div', { class: 'flex flex-col gap-1' }, [
|
||||
h('div', { class: 'px-2 py-1.5 text-sm font-semibold' }, `Change ${formatTaskType(taskType)} Status`),
|
||||
...allStatusOptions.map((statusOption) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'justify-start',
|
||||
onClick: () => {
|
||||
meta.onBulkTaskStatusChange?.(taskType, statusOption.id as TaskStatus)
|
||||
isPopoverOpen.value = false
|
||||
},
|
||||
},
|
||||
() => h(TaskStatusBadge, { status: statusOption, compact: true })
|
||||
)
|
||||
),
|
||||
])
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
[formatTaskType(taskType), getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
const status = asset.task_status?.[taskType] || TaskStatus.NOT_STARTED
|
||||
const taskId = asset.task_details?.find(t => t.task_type === taskType)?.task_id
|
||||
|
||||
return h(EditableTaskStatus, {
|
||||
key: `${asset.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
|
||||
assetId: asset.id,
|
||||
taskType,
|
||||
status,
|
||||
taskId,
|
||||
projectId: meta.projectId,
|
||||
onStatusUpdated: (assetId: number, taskType: string, newStatus: TaskStatus) => {
|
||||
meta.onTaskStatusUpdated(assetId, taskType, newStatus)
|
||||
},
|
||||
})
|
||||
},
|
||||
enableSorting: true,
|
||||
})
|
||||
})
|
||||
|
||||
// Description column
|
||||
columns.push({
|
||||
accessorKey: 'description',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
['Description', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
return h('span', { class: 'text-sm text-muted-foreground' }, asset.description || '-')
|
||||
},
|
||||
})
|
||||
|
||||
// Updated At column
|
||||
columns.push({
|
||||
accessorKey: 'updated_at',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
['Updated', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
const date = new Date(asset.updated_at)
|
||||
return h('span', { class: 'text-sm text-muted-foreground' }, date.toLocaleDateString())
|
||||
},
|
||||
})
|
||||
|
||||
// Actions column
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const asset = row.original
|
||||
return h(
|
||||
DropdownMenu,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuTrigger,
|
||||
{
|
||||
asChild: true
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'h-8 w-8 p-0',
|
||||
onMouseDown: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
},
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => h(MoreHorizontal, { class: 'h-4 w-4' }),
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuContent,
|
||||
{
|
||||
align: 'end'
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
meta.onEdit(asset)
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(Edit, { class: 'h-4 w-4 mr-2' }),
|
||||
'Edit Asset',
|
||||
],
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
meta.onViewTasks(asset)
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(ListTodo, { class: 'h-4 w-4 mr-2' }),
|
||||
'View Tasks',
|
||||
],
|
||||
}
|
||||
),
|
||||
h(DropdownMenuSeparator),
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
meta.onDelete(asset)
|
||||
},
|
||||
class: 'text-destructive focus:text-destructive',
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(Trash2, { class: 'h-4 w-4 mr-2' }),
|
||||
'Delete Asset',
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
})
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatTaskType(taskType: string): string {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
|
||||
function formatCategory(category: AssetCategory): string {
|
||||
return category
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function formatStatus(status: AssetStatus): string {
|
||||
return status
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function getStatusVariant(status: AssetStatus): 'default' | 'secondary' | 'outline' {
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-sm space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<h1 class="text-3xl font-bold">Sign In</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Enter your credentials to access your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="isLoading">
|
||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="underline underline-offset-4 hover:text-primary">
|
||||
Sign up
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { LoginCredentials } from '@/types/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive<LoginCredentials>({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const isLoading = computed(() => authStore.isLoading)
|
||||
const error = computed(() => authStore.error)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
authStore.clearError()
|
||||
await authStore.login(form)
|
||||
|
||||
// Redirect to dashboard after successful login
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error is handled by the store
|
||||
console.error('Login error:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-sm space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<h1 class="text-3xl font-bold">Create Account</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Enter your information to create an account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="first_name">First Name</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
v-model="form.first_name"
|
||||
placeholder="John"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="last_name">Last Name</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
v-model="form.last_name"
|
||||
placeholder="Doe"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Create a password"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="confirm_password">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="isLoading || !isFormValid">
|
||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
Create Account
|
||||
</Button>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="successMessage" class="text-sm text-green-600 text-center">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-sm">
|
||||
Already have an account?
|
||||
<router-link to="/login" class="underline underline-offset-4 hover:text-primary">
|
||||
Sign in
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { RegisterData } from '@/types/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive<RegisterData>({
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: ''
|
||||
})
|
||||
|
||||
const confirmPassword = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const isLoading = computed(() => authStore.isLoading)
|
||||
const error = computed(() => authStore.error)
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.email &&
|
||||
form.password &&
|
||||
form.first_name &&
|
||||
form.last_name &&
|
||||
form.password === confirmPassword.value
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (form.password !== confirmPassword.value) {
|
||||
authStore.error = 'Passwords do not match'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
authStore.clearError()
|
||||
await authStore.register(form)
|
||||
|
||||
successMessage.value = 'Account created successfully! Please wait for admin approval before signing in.'
|
||||
|
||||
// Redirect to login after a delay
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
// Error is handled by the store
|
||||
console.error('Registration error:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Card class="cursor-pointer hover:shadow-md transition-shadow" @click="$emit('select', episode)">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-primary/10">
|
||||
<Film class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle class="text-lg">{{ episode.name }}</CardTitle>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" class="text-xs">
|
||||
Episode {{ episode.episode_number }}
|
||||
</Badge>
|
||||
<Badge :variant="getStatusVariant(episode.status)">
|
||||
{{ formatStatus(episode.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" @click.stop>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click.stop="$emit('edit', episode)">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit Episode
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click.stop="$emit('view-shots', episode)">
|
||||
<Camera class="h-4 w-4 mr-2" />
|
||||
View Shots
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click.stop="$emit('delete', episode)"
|
||||
class="text-destructive"
|
||||
v-if="canDelete"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete Episode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm text-muted-foreground mb-4" v-if="episode.description">
|
||||
{{ episode.description }}
|
||||
</p>
|
||||
|
||||
<!-- Progress Indicators -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Shots</span>
|
||||
<span class="font-medium">{{ episode.shot_count || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-muted-foreground">Progress</span>
|
||||
<span class="font-medium">{{ progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="mt-4 pt-4 border-t">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Created {{ formatDate(episode.created_at) }}</span>
|
||||
<span v-if="episode.updated_at !== episode.created_at">
|
||||
Updated {{ formatDate(episode.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Film, MoreHorizontal, Edit, Camera, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { Episode } from '@/services/episode'
|
||||
|
||||
interface Props {
|
||||
episode: Episode
|
||||
canDelete?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
canDelete: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
select: [episode: Episode]
|
||||
edit: [episode: Episode]
|
||||
'view-shots': [episode: Episode]
|
||||
delete: [episode: Episode]
|
||||
}>()
|
||||
|
||||
const progressPercentage = computed(() => {
|
||||
// For now, return a mock progress based on status
|
||||
// In a real implementation, this would be calculated from shot completion
|
||||
switch (props.episode.status) {
|
||||
case 'planning': return 0
|
||||
case 'in_progress': return 45
|
||||
case 'on_hold': return 30
|
||||
case 'completed': return 100
|
||||
case 'cancelled': return 0
|
||||
default: return 0
|
||||
}
|
||||
})
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'planning': return 'secondary'
|
||||
case 'in_progress': return 'default'
|
||||
case 'on_hold': return 'outline'
|
||||
case 'completed': return 'success'
|
||||
case 'cancelled': return 'destructive'
|
||||
default: return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'EpisodeCard'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<Label class="text-sm font-medium">Episode:</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
:model-value="selectedEpisodeId?.toString() || 'all'"
|
||||
@update:model-value="handleEpisodeChange"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<SelectTrigger class="w-full sm:w-64">
|
||||
<SelectValue placeholder="Select episode..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<!-- All Episodes Option -->
|
||||
<SelectItem value="all">
|
||||
All Episodes
|
||||
</SelectItem>
|
||||
|
||||
<SelectSeparator v-if="episodes.length > 0" />
|
||||
|
||||
<!-- Loading State -->
|
||||
<SelectItem v-if="isLoading" value="loading" disabled>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
<span>Loading episodes...</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<!-- Error State -->
|
||||
<SelectItem v-else-if="error" value="error" disabled>
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-destructive" />
|
||||
<span class="text-destructive">{{ error }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<!-- Episodes List -->
|
||||
<SelectItem
|
||||
v-else
|
||||
v-for="episode in sortedEpisodes"
|
||||
:key="episode.id"
|
||||
:value="episode.id.toString()"
|
||||
>
|
||||
{{ episode.name }}
|
||||
</SelectItem>
|
||||
|
||||
<SelectSeparator v-if="canCreateEpisodes && episodes.length > 0" />
|
||||
|
||||
<!-- Create Episode Option -->
|
||||
<SelectItem
|
||||
v-if="canCreateEpisodes"
|
||||
value="create"
|
||||
@click="handleCreateEpisode"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Plus class="h-4 w-4" />
|
||||
<span>Create Episode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshEpisodes"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<RefreshCw :class="['h-4 w-4', isLoading && 'animate-spin']" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, AlertCircle, RefreshCw } from 'lucide-vue-next'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectSeparator,
|
||||
SelectTrigger, SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { episodeService, type Episode } from '@/services/episode'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
selectedEpisodeId?: number | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'episode-selected', episodeId: number | null): void
|
||||
(e: 'create-episode'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Reactive state
|
||||
const episodes = ref<Episode[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const canCreateEpisodes = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
|
||||
const sortedEpisodes = computed(() => {
|
||||
return [...episodes.value].sort((a, b) => {
|
||||
// Sort by episode number if available, otherwise by name
|
||||
if (a.episode_number && b.episode_number) {
|
||||
return a.episode_number - b.episode_number
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadEpisodes = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
episodes.value = await episodeService.getProjectEpisodes(props.projectId)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load episodes'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshEpisodes = async () => {
|
||||
await loadEpisodes()
|
||||
}
|
||||
|
||||
const handleEpisodeChange = (value: string) => {
|
||||
if (value === 'create') {
|
||||
handleCreateEpisode()
|
||||
return
|
||||
}
|
||||
|
||||
if (value === 'all') {
|
||||
emit('episode-selected', null)
|
||||
} else {
|
||||
const episodeId = parseInt(value)
|
||||
if (!isNaN(episodeId)) {
|
||||
emit('episode-selected', episodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateEpisode = () => {
|
||||
emit('create-episode')
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.projectId, (newProjectId) => {
|
||||
if (newProjectId) {
|
||||
loadEpisodes()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (props.projectId) {
|
||||
loadEpisodes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<Label for="name">Episode Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="Enter episode name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="episode_number">Episode Number</Label>
|
||||
<Input
|
||||
id="episode_number"
|
||||
v-model.number="form.episode_number"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Enter episode number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="Brief description of the episode"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="status">Status</Label>
|
||||
<Select :model-value="form.status" @update:model-value="form.status = $event">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="planning">Planning</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="on_hold">On Hold</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="outline" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" :disabled="isSubmitting">
|
||||
<div v-if="isSubmitting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{{ isEditing ? 'Update Episode' : 'Create Episode' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import type { Episode, EpisodeCreate, EpisodeUpdate } from '@/services/episode'
|
||||
|
||||
interface Props {
|
||||
episode?: Episode
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isSubmitting: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [data: EpisodeCreate | EpisodeUpdate]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const isEditing = computed(() => !!props.episode)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
episode_number: 1,
|
||||
description: '',
|
||||
status: 'planning' as const
|
||||
})
|
||||
|
||||
// Initialize form with episode data if editing
|
||||
watch(() => props.episode, (episode) => {
|
||||
if (episode) {
|
||||
form.value = {
|
||||
name: episode.name,
|
||||
episode_number: episode.episode_number,
|
||||
description: episode.description || '',
|
||||
status: episode.status
|
||||
}
|
||||
} else {
|
||||
// Reset form for new episode
|
||||
form.value = {
|
||||
name: '',
|
||||
episode_number: 1,
|
||||
description: '',
|
||||
status: 'planning'
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
episode_number: form.value.episode_number,
|
||||
description: form.value.description || undefined,
|
||||
status: form.value.status
|
||||
}
|
||||
|
||||
emit('submit', data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed } from 'vue'
|
||||
export default {
|
||||
name: 'EpisodeForm'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Header with filters -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search episodes..."
|
||||
class="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Select :model-value="statusFilter" @update:model-value="statusFilter = $event">
|
||||
<SelectTrigger class="w-40">
|
||||
<SelectValue placeholder="All Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="planning">Planning</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="on_hold">On Hold</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="toggleView">
|
||||
<component :is="viewMode === 'grid' ? List : Grid3X3" class="h-4 w-4 mr-2" />
|
||||
{{ viewMode === 'grid' ? 'List View' : 'Grid View' }}
|
||||
</Button>
|
||||
<Button @click="$emit('create')" v-if="canCreate">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
New Episode
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 episodes...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load episodes</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="$emit('retry')" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Episodes Grid/List -->
|
||||
<div v-else-if="filteredEpisodes.length > 0">
|
||||
<!-- Grid View -->
|
||||
<div v-if="viewMode === 'grid'" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<EpisodeCard
|
||||
v-for="episode in filteredEpisodes"
|
||||
:key="episode.id"
|
||||
:episode="episode"
|
||||
:can-delete="canDelete"
|
||||
@select="$emit('select', episode)"
|
||||
@edit="$emit('edit', episode)"
|
||||
@view-shots="$emit('view-shots', episode)"
|
||||
@delete="$emit('delete', episode)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else class="space-y-2">
|
||||
<div class="grid grid-cols-12 gap-4 px-4 py-2 text-sm font-medium text-muted-foreground border-b">
|
||||
<div class="col-span-1">#</div>
|
||||
<div class="col-span-4">Name</div>
|
||||
<div class="col-span-2">Status</div>
|
||||
<div class="col-span-2">Shots</div>
|
||||
<div class="col-span-2">Progress</div>
|
||||
<div class="col-span-1">Actions</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="episode in filteredEpisodes"
|
||||
:key="episode.id"
|
||||
class="grid grid-cols-12 gap-4 px-4 py-3 hover:bg-muted/50 rounded-lg cursor-pointer transition-colors"
|
||||
@click="$emit('select', episode)"
|
||||
>
|
||||
<div class="col-span-1 flex items-center">
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{{ episode.episode_number }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="col-span-4 flex items-center">
|
||||
<div>
|
||||
<div class="font-medium">{{ episode.name }}</div>
|
||||
<div class="text-sm text-muted-foreground" v-if="episode.description">
|
||||
{{ episode.description.substring(0, 60) }}{{ episode.description.length > 60 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center">
|
||||
<Badge :variant="getStatusVariant(episode.status)">
|
||||
{{ formatStatus(episode.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center">
|
||||
<span class="font-medium">{{ episode.shot_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<div class="flex-1 bg-secondary rounded-full h-2">
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: `${getProgressPercentage(episode)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium">{{ getProgressPercentage(episode) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-1 flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" @click.stop>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click.stop="$emit('edit', episode)">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit Episode
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click.stop="$emit('view-shots', episode)">
|
||||
<Camera class="h-4 w-4 mr-2" />
|
||||
View Shots
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click.stop="$emit('delete', episode)"
|
||||
class="text-destructive"
|
||||
v-if="canDelete"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete Episode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<Film class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">
|
||||
{{ searchQuery || statusFilter !== 'all' ? 'No episodes found' : 'No episodes yet' }}
|
||||
</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{{ searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your search or filter criteria'
|
||||
: 'Create your first episode to start organizing shots and tasks'
|
||||
}}
|
||||
</p>
|
||||
<Button @click="$emit('create')" v-if="canCreate && !searchQuery && statusFilter === 'all'">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create Episode
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
Search, Plus, List, Grid3X3, AlertCircle, RefreshCw, Film,
|
||||
MoreHorizontal, Edit, Camera, Trash2
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import EpisodeCard from './EpisodeCard.vue'
|
||||
import type { Episode } from '@/services/episode'
|
||||
|
||||
interface Props {
|
||||
episodes: Episode[]
|
||||
isLoading?: boolean
|
||||
error?: string | null
|
||||
canCreate?: boolean
|
||||
canDelete?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
canCreate: false,
|
||||
canDelete: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
create: []
|
||||
select: [episode: Episode]
|
||||
edit: [episode: Episode]
|
||||
'view-shots': [episode: Episode]
|
||||
delete: [episode: Episode]
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const viewMode = ref<'grid' | 'list'>('grid')
|
||||
|
||||
const filteredEpisodes = computed(() => {
|
||||
let filtered = props.episodes
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(episode =>
|
||||
episode.name.toLowerCase().includes(query) ||
|
||||
episode.description?.toLowerCase().includes(query) ||
|
||||
episode.episode_number.toString().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter.value !== 'all') {
|
||||
filtered = filtered.filter(episode => episode.status === statusFilter.value)
|
||||
}
|
||||
|
||||
// Sort by episode number
|
||||
return filtered.sort((a, b) => a.episode_number - b.episode_number)
|
||||
})
|
||||
|
||||
const toggleView = () => {
|
||||
viewMode.value = viewMode.value === 'grid' ? 'list' : 'grid'
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'planning': return 'secondary'
|
||||
case 'in_progress': return 'default'
|
||||
case 'on_hold': return 'outline'
|
||||
case 'completed': return 'success'
|
||||
case 'cancelled': return 'destructive'
|
||||
default: return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
const getProgressPercentage = (episode: Episode) => {
|
||||
// Mock progress calculation based on status
|
||||
// In a real implementation, this would be calculated from shot completion
|
||||
switch (episode.status) {
|
||||
case 'planning': return 0
|
||||
case 'in_progress': return 45
|
||||
case 'on_hold': return 30
|
||||
case 'completed': return 100
|
||||
case 'cancelled': return 0
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'EpisodeList'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Upload Example</CardTitle>
|
||||
<CardDescription>
|
||||
Example showing how to integrate upload limit display and validation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Upload Limit Display -->
|
||||
<UploadLimitDisplay />
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="space-y-2">
|
||||
<Label for="file-upload">Select File</Label>
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
:disabled="uploading"
|
||||
accept=".mov,.mp4,.avi,.mkv,.webm,.jpg,.jpeg,.png,.exr,.tiff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File Info -->
|
||||
<div v-if="selectedFile" class="text-sm space-y-1">
|
||||
<p><strong>File:</strong> {{ selectedFile.name }}</p>
|
||||
<p><strong>Size:</strong> {{ formatFileSize(selectedFile.size) }}</p>
|
||||
<p><strong>Type:</strong> {{ getFileType(selectedFile.name) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Validation Errors -->
|
||||
<div v-if="validationError" class="text-sm text-destructive">
|
||||
{{ validationError }}
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<Button
|
||||
@click="uploadFile"
|
||||
:disabled="!selectedFile || !!validationError || uploading"
|
||||
class="w-full"
|
||||
>
|
||||
<Loader2 v-if="uploading" class="h-4 w-4 animate-spin mr-2" />
|
||||
<Upload v-else class="h-4 w-4 mr-2" />
|
||||
{{ uploading ? 'Uploading...' : 'Upload File' }}
|
||||
</Button>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="uploadSuccess" class="text-sm text-green-600">
|
||||
File uploaded successfully!
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Upload, Loader2 } from 'lucide-vue-next'
|
||||
import UploadLimitDisplay from '@/components/settings/UploadLimitDisplay.vue'
|
||||
import { validateFile, formatFileSize, isMovieFile, isImageFile } from '@/utils/fileValidation'
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const validationError = ref<string | null>(null)
|
||||
const uploading = ref(false)
|
||||
const uploadSuccess = ref(false)
|
||||
|
||||
function getFileType(fileName: string): string {
|
||||
if (isMovieFile(fileName)) return 'Movie'
|
||||
if (isImageFile(fileName)) return 'Image'
|
||||
return 'Other'
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
||||
if (!file) {
|
||||
selectedFile.value = null
|
||||
validationError.value = null
|
||||
return
|
||||
}
|
||||
|
||||
selectedFile.value = file
|
||||
uploadSuccess.value = false
|
||||
|
||||
// Validate file
|
||||
const result = await validateFile(file)
|
||||
validationError.value = result.isValid ? null : result.error || 'Invalid file'
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!selectedFile.value || validationError.value) return
|
||||
|
||||
uploading.value = true
|
||||
|
||||
try {
|
||||
// Simulate upload delay
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Here you would make the actual API call to upload the file
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', selectedFile.value)
|
||||
// await api.post('/tasks/123/attachments', formData)
|
||||
|
||||
uploadSuccess.value = true
|
||||
selectedFile.value = null
|
||||
validationError.value = null
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('file-upload') as HTMLInputElement
|
||||
if (fileInput) fileInput.value = ''
|
||||
|
||||
} catch (error) {
|
||||
validationError.value = 'Upload failed. Please try again.'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<header class="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<!-- Sidebar Toggle -->
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<Separator orientation="vertical" class="mr-2 h-4" />
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<Breadcrumb class="flex-1">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||
<BreadcrumbLink v-if="crumb.href" :href="crumb.href">
|
||||
{{ crumb.label }}
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbPage v-else :class="{ 'font-semibold': crumb.isActive }">
|
||||
{{ crumb.label }}
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbSeparator v-if="index < breadcrumbs.length - 1" />
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<!-- Header Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Theme Toggle -->
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Notifications -->
|
||||
<NotificationCenter />
|
||||
|
||||
<!-- User Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="relative h-10 w-10 rounded-full">
|
||||
<Avatar class="h-10 w-10">
|
||||
<AvatarImage
|
||||
v-if="user?.avatar_url"
|
||||
:src="getAvatarUrl(user.avatar_url)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${user?.first_name} ${user?.last_name}`"
|
||||
/>
|
||||
<AvatarFallback>{{ userInitials }}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<p class="text-sm font-medium leading-none">{{ user?.first_name }} {{ user?.last_name }}</p>
|
||||
<p class="text-xs leading-none text-muted-foreground">{{ user?.email }}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<router-link to="/profile" class="flex items-center">
|
||||
<User class="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as-child>
|
||||
<router-link to="/settings" class="flex items-center">
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</router-link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleLogout" class="text-destructive">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { User, Settings, LogOut } from 'lucide-vue-next'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { BreadcrumbService, type BreadcrumbItem as BreadcrumbData } from '@/services/breadcrumb'
|
||||
import ThemeToggle from '@/components/ui/theme/ThemeToggle.vue'
|
||||
import NotificationCenter from './NotificationCenter.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value) return '?'
|
||||
return `${user.value.first_name.charAt(0)}${user.value.last_name.charAt(0)}`.toUpperCase()
|
||||
})
|
||||
|
||||
const getAvatarUrl = (url: string | null | undefined) => {
|
||||
if (!url) return ''
|
||||
// If it's already a full URL, return it
|
||||
if (url.startsWith('http')) return url
|
||||
// Use direct static file serving
|
||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `/${cleanUrl}`
|
||||
}
|
||||
|
||||
// Generate breadcrumbs based on current route with enhanced context
|
||||
const breadcrumbs = ref<BreadcrumbData[]>([])
|
||||
|
||||
const updateBreadcrumbs = async () => {
|
||||
try {
|
||||
breadcrumbs.value = await BreadcrumbService.generateBreadcrumbs(route)
|
||||
} catch (error) {
|
||||
console.error('Failed to generate breadcrumbs:', error)
|
||||
// Fallback to simple breadcrumbs
|
||||
const pathSegments = route.path.split('/').filter(Boolean)
|
||||
const crumbs = [{ label: 'Home', href: '/' }]
|
||||
|
||||
let currentPath = ''
|
||||
pathSegments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`
|
||||
const isLast = index === pathSegments.length - 1
|
||||
|
||||
const label = segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
|
||||
|
||||
crumbs.push({
|
||||
label,
|
||||
href: isLast ? undefined : currentPath,
|
||||
isActive: isLast
|
||||
})
|
||||
})
|
||||
|
||||
breadcrumbs.value = crumbs
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for route changes to update breadcrumbs
|
||||
watch(route, updateBreadcrumbs, { immediate: true })
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<div class="flex h-screen w-full">
|
||||
<!-- Main Sidebar -->
|
||||
<AppSidebar />
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<SidebarInset class="flex-1 flex flex-col">
|
||||
<!-- Header -->
|
||||
<AppHeader />
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel (conditionally shown) -->
|
||||
<div
|
||||
v-if="showDetailPanel"
|
||||
class="w-80 border-l bg-background overflow-auto"
|
||||
>
|
||||
<slot name="detail-panel">
|
||||
<!-- Default detail panel content -->
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold mb-4">Details</h3>
|
||||
<p class="text-muted-foreground">Select an item to view details</p>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
|
||||
// Show detail panel based on route or store state
|
||||
const route = useRoute()
|
||||
const showDetailPanel = computed(() => {
|
||||
// Show detail panel for certain routes or when an item is selected
|
||||
return route.meta?.showDetailPanel || false
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<Sidebar variant="inset" v-bind="props">
|
||||
<SidebarHeader>
|
||||
<ProjectSwitcher v-if="userRole !== 'developer'" />
|
||||
|
||||
<!-- Developer header (no project switching) -->
|
||||
<SidebarMenu v-else>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<router-link to="/" class="flex items-center gap-2">
|
||||
<div class="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Clapperboard class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span class="truncate font-semibold">VFX Studio</span>
|
||||
<span class="truncate text-xs">Developer Tools</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<!-- Main Navigation -->
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in navigationItems" :key="item.title">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<!-- Projects Section -->
|
||||
<!-- <SidebarGroup v-if="userRole !== 'developer'">
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="project in recentProjects" :key="project.id">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? project.name : undefined">
|
||||
<router-link :to="`/projects/${project.id}`" class="flex items-center gap-2">
|
||||
<Folder class="size-4" />
|
||||
<span class="truncate group-data-[collapsible=icon]:hidden">{{ project.name }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup> -->
|
||||
|
||||
<!-- Admin Tools (only for admin users) -->
|
||||
<SidebarGroup v-if="authStore.isAdmin">
|
||||
<SidebarGroupLabel>Administration</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in adminItems" :key="item.title">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<!-- Developer Tools (only for developer role) -->
|
||||
<SidebarGroup v-if="userRole === 'developer'">
|
||||
<SidebarGroupLabel>Developer Tools</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in developerItems" :key="item.title">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<UserMenu />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
SidebarProps
|
||||
} from '@/components/ui/sidebar'
|
||||
import {
|
||||
Clapperboard,
|
||||
Home,
|
||||
CheckSquare,
|
||||
FolderOpen,
|
||||
Users,
|
||||
Settings,
|
||||
Folder,
|
||||
Key,
|
||||
Database,
|
||||
BarChart3,
|
||||
FileText,
|
||||
RotateCcw,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ProjectSwitcher from './ProjectSwitcher.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
const authStore = useAuthStore()
|
||||
const { state } = useSidebar()
|
||||
const user = computed(() => authStore.user)
|
||||
const userRole = computed(() => authStore.user?.role || 'artist')
|
||||
|
||||
// Check if sidebar is collapsed
|
||||
const isCollapsed = computed(() => state.value === 'collapsed')
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
collapsible: "icon",
|
||||
})
|
||||
// Navigation items based on user role
|
||||
const navigationItems = computed(() => {
|
||||
const baseItems = [
|
||||
{ title: 'Dashboard', url: '/', icon: Home },
|
||||
{ title: 'My Tasks', url: '/tasks', icon: CheckSquare },
|
||||
]
|
||||
|
||||
if (userRole.value === 'coordinator' || authStore.isAdmin) {
|
||||
baseItems.push(
|
||||
{ title: 'Projects', url: '/projects', icon: FolderOpen },
|
||||
{ title: 'Team', url: '/users', icon: Users }
|
||||
)
|
||||
}
|
||||
|
||||
if (userRole.value === 'director' || authStore.isAdmin) {
|
||||
baseItems.push(
|
||||
{ title: 'Reviews', url: '/reviews', icon: CheckSquare }
|
||||
)
|
||||
}
|
||||
|
||||
if (authStore.isAdmin) {
|
||||
baseItems.push(
|
||||
{ title: 'Settings', url: '/settings', icon: Settings }
|
||||
)
|
||||
}
|
||||
|
||||
return baseItems
|
||||
})
|
||||
|
||||
// Admin-specific navigation items
|
||||
const adminItems = computed(() => [
|
||||
{ title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw },
|
||||
])
|
||||
|
||||
// Developer-specific navigation items
|
||||
const developerItems = computed(() => [
|
||||
{ title: 'API Keys', url: '/developer/api-keys', icon: Key },
|
||||
{ title: 'All Projects', url: '/developer/projects', icon: Database },
|
||||
{ title: 'All Tasks', url: '/developer/tasks', icon: CheckSquare },
|
||||
{ title: 'Usage Analytics', url: '/developer/analytics', icon: BarChart3 },
|
||||
{ title: 'Documentation', url: '/developer/docs', icon: FileText }
|
||||
])
|
||||
|
||||
// Mock recent projects - this would come from a store in real implementation
|
||||
const recentProjects = computed(() => [
|
||||
{ id: 1, name: 'Project Alpha' },
|
||||
{ id: 2, name: 'Project Beta' },
|
||||
{ id: 3, name: 'Project Gamma' }
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<Popover v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="relative">
|
||||
<Bell class="h-5 w-5" />
|
||||
<span v-if="unreadCount > 0" class="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96 p-0" align="end">
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<h3 class="font-semibold">Notifications</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="unreadCount > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="handleRefresh"
|
||||
:disabled="loading"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea class="h-[400px]">
|
||||
<div v-if="loading && notifications.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
Loading notifications...
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
<Bell class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No notifications</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="p-4 hover:bg-accent cursor-pointer transition-colors"
|
||||
:class="{ 'bg-accent/50': !notification.read }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<component
|
||||
:is="getNotificationIcon(notification.type)"
|
||||
class="h-5 w-5"
|
||||
:class="getNotificationColor(notification.priority)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="font-medium text-sm" :class="{ 'font-semibold': !notification.read }">
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 flex-shrink-0"
|
||||
@click.stop="handleDelete(notification.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
{{ formatTime(notification.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div class="p-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
@click="handleViewAll"
|
||||
>
|
||||
View all notifications
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Bell,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import type { Notification } from '@/types/notification'
|
||||
import { NotificationType, NotificationPriority } from '@/types/notification'
|
||||
|
||||
const router = useRouter()
|
||||
const notificationsStore = useNotificationsStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const notifications = computed(() => notificationsStore.notifications)
|
||||
const unreadCount = computed(() => notificationsStore.unreadCount)
|
||||
|
||||
let stopPolling: (() => void) | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await loadNotifications()
|
||||
await notificationsStore.fetchStats()
|
||||
|
||||
// Start polling for new notifications every 30 seconds
|
||||
stopPolling = notificationsStore.startPolling(30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (stopPolling) {
|
||||
stopPolling()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadNotifications() {
|
||||
loading.value = true
|
||||
try {
|
||||
await notificationsStore.fetchNotifications()
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
await loadNotifications()
|
||||
await notificationsStore.fetchStats()
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
try {
|
||||
await notificationsStore.markAllAsRead()
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'All notifications marked as read'
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to mark notifications as read'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationClick(notification: Notification) {
|
||||
// Mark as read
|
||||
if (!notification.read) {
|
||||
await notificationsStore.markAsRead([notification.id])
|
||||
}
|
||||
|
||||
// Navigate to relevant page
|
||||
if (notification.task_id) {
|
||||
router.push(`/tasks?taskId=${notification.task_id}`)
|
||||
} else if (notification.project_id) {
|
||||
router.push(`/projects/${notification.project_id}`)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function handleDelete(notificationId: number) {
|
||||
try {
|
||||
await notificationsStore.deleteNotification(notificationId)
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to delete notification'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewAll() {
|
||||
router.push('/notifications')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function getNotificationIcon(type: NotificationType) {
|
||||
switch (type) {
|
||||
case NotificationType.TASK_ASSIGNED:
|
||||
return FileText
|
||||
case NotificationType.TASK_STATUS_CHANGED:
|
||||
return CheckCircle
|
||||
case NotificationType.SUBMISSION_REVIEWED:
|
||||
return CheckCircle
|
||||
case NotificationType.WORK_SUBMITTED:
|
||||
return FileText
|
||||
case NotificationType.DEADLINE_APPROACHING:
|
||||
return Clock
|
||||
case NotificationType.PROJECT_UPDATE:
|
||||
return AlertCircle
|
||||
case NotificationType.COMMENT_ADDED:
|
||||
return MessageSquare
|
||||
default:
|
||||
return Bell
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationColor(priority: NotificationPriority) {
|
||||
switch (priority) {
|
||||
case NotificationPriority.URGENT:
|
||||
return 'text-red-500'
|
||||
case NotificationPriority.HIGH:
|
||||
return 'text-orange-500'
|
||||
case NotificationPriority.NORMAL:
|
||||
return 'text-blue-500'
|
||||
case NotificationPriority.LOW:
|
||||
return 'text-gray-500'
|
||||
default:
|
||||
return 'text-blue-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()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
</div>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-destructive text-destructive-foreground"
|
||||
>
|
||||
<AlertCircle class="size-4" />
|
||||
</div>
|
||||
<!-- Normal State -->
|
||||
<div
|
||||
v-else
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<component :is="activeProject.icon" class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">
|
||||
{{ isLoading ? 'Loading...' : error ? 'Error' : activeProject.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs">
|
||||
{{ isLoading ? 'Fetching projects' : error ? 'Failed to load' : activeProject.status }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
align="start"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="text-xs text-muted-foreground">
|
||||
Projects
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<!-- Loading State in Dropdown -->
|
||||
<DropdownMenuItem v-if="isLoading" class="gap-2 p-2" disabled>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
<span class="text-muted-foreground">Loading projects...</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Error State in Dropdown -->
|
||||
<template v-else-if="error">
|
||||
<DropdownMenuItem class="gap-2 p-2" disabled>
|
||||
<AlertCircle class="size-4 text-destructive" />
|
||||
<span class="text-destructive text-sm">{{ error }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="gap-2 p-2" @click="refreshProjects">
|
||||
<RefreshCw class="size-4" />
|
||||
<span>Retry</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<!-- Projects List -->
|
||||
<template v-else>
|
||||
<DropdownMenuItem
|
||||
v-for="(project, index) in projects"
|
||||
:key="project.id"
|
||||
class="gap-2 p-2"
|
||||
@click="setActiveProject(project)"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-sm border"
|
||||
>
|
||||
<component :is="project.icon" class="size-4 shrink-0" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ project.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
project.status
|
||||
}}</span>
|
||||
</div>
|
||||
<DropdownMenuShortcut>⌘{{ index + 1 }}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="gap-2 p-2" @click="refreshProjects">
|
||||
<RefreshCw class="size-4" />
|
||||
<div class="font-medium text-muted-foreground">Refresh projects</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="canCreateProjects"
|
||||
class="gap-2 p-2"
|
||||
@click="handleCreateProject"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-background"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<div class="font-medium text-muted-foreground">Create project</div>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ChevronsUpDown, Plus, AlertCircle, RefreshCw } from "lucide-vue-next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useProjectsStore } from "@/stores/projects";
|
||||
|
||||
const router = useRouter();
|
||||
const { isMobile } = useSidebar();
|
||||
const authStore = useAuthStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
// Get projects and active project from store
|
||||
const projects = computed(() => projectsStore.availableProjects);
|
||||
const activeProject = computed(() => projectsStore.currentProject);
|
||||
const isLoading = computed(() => projectsStore.isLoading);
|
||||
const error = computed(() => projectsStore.error);
|
||||
|
||||
// Check if user can create projects
|
||||
const canCreateProjects = computed(() => {
|
||||
const user = authStore.user;
|
||||
return user?.is_admin || user?.role === "coordinator";
|
||||
});
|
||||
|
||||
const setActiveProject = (project: any) => {
|
||||
projectsStore.setActiveProject(project);
|
||||
|
||||
// Navigate to project-specific view
|
||||
if (project.id === 0) {
|
||||
// "All Projects" view
|
||||
router.push("/projects");
|
||||
} else {
|
||||
// Specific project view - navigate to overview tab
|
||||
router.push(`/projects/${project.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = () => {
|
||||
if (canCreateProjects.value) {
|
||||
router.push("/projects/new");
|
||||
}
|
||||
};
|
||||
|
||||
const refreshProjects = async () => {
|
||||
try {
|
||||
await projectsStore.fetchProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh projects:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Set active project based on current route
|
||||
const updateActiveProjectFromRoute = () => {
|
||||
const currentPath = router.currentRoute.value.path;
|
||||
|
||||
if (currentPath.startsWith("/projects/")) {
|
||||
const pathSegments = currentPath.split("/");
|
||||
if (pathSegments[2] === "new") {
|
||||
// Creating new project, keep current active project
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = parseInt(pathSegments[2]);
|
||||
if (!isNaN(projectId)) {
|
||||
const project = projectsStore.getProjectById(projectId);
|
||||
if (project) {
|
||||
projectsStore.setActiveProject(project);
|
||||
}
|
||||
}
|
||||
} else if (currentPath === "/projects") {
|
||||
projectsStore.setActiveProject(projectsStore.allProjectsView);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for route changes
|
||||
watch(
|
||||
() => router.currentRoute.value.path,
|
||||
() => {
|
||||
updateActiveProjectFromRoute();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Watch for authentication changes to fetch projects
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(isAuthenticated) => {
|
||||
if (isAuthenticated && projectsStore.projects.length === 0 && !projectsStore.isLoading) {
|
||||
refreshProjects();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Ensure projects are loaded on component mount
|
||||
onMounted(() => {
|
||||
if (authStore.isAuthenticated && projectsStore.projects.length === 0 && !projectsStore.isLoading) {
|
||||
refreshProjects();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="userAvatar" :alt="userDisplayName" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ userInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userDisplayName }}</span>
|
||||
<span class="truncate text-xs capitalize">{{ user?.role }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="userAvatar" :alt="userDisplayName" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ userInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userDisplayName }}</span>
|
||||
<span class="truncate text-xs">{{ user?.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Role-specific features -->
|
||||
<DropdownMenuGroup v-if="showRoleFeatures">
|
||||
<DropdownMenuItem v-if="user?.role === 'developer'" @click="navigateTo('/developer/api-keys')">
|
||||
<Key class="size-4" />
|
||||
API Keys
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="isAdminOrCoordinator" @click="navigateTo('/users')">
|
||||
<Users class="size-4" />
|
||||
Team Management
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="user?.is_admin" @click="navigateTo('/settings')">
|
||||
<Settings class="size-4" />
|
||||
System Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator v-if="showRoleFeatures" />
|
||||
|
||||
<!-- User account options -->
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="navigateTo('/profile')">
|
||||
<User class="size-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="navigateTo('/settings/preferences')">
|
||||
<Palette class="size-4" />
|
||||
Preferences
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="toggleNotifications">
|
||||
<Bell class="size-4" />
|
||||
Notifications
|
||||
<span class="ml-auto text-xs text-muted-foreground">
|
||||
{{ notificationsEnabled ? 'On' : 'Off' }}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Help and support -->
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="navigateTo('/help')">
|
||||
<HelpCircle class="size-4" />
|
||||
Help & Support
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="showKeyboardShortcuts">
|
||||
<Keyboard class="size-4" />
|
||||
Keyboard Shortcuts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<!-- Logout -->
|
||||
<DropdownMenuItem @click="handleLogout" class="text-destructive focus:text-destructive">
|
||||
<LogOut class="size-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
LogOut,
|
||||
User,
|
||||
Settings,
|
||||
Bell,
|
||||
Key,
|
||||
Users,
|
||||
Palette,
|
||||
HelpCircle,
|
||||
Keyboard,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const { isMobile } = useSidebar()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// User data
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
// User display information
|
||||
const userDisplayName = computed(() => {
|
||||
if (!user.value) return 'Guest'
|
||||
return `${user.value.first_name} ${user.value.last_name}`
|
||||
})
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value) return 'G'
|
||||
const firstInitial = user.value.first_name?.charAt(0) || ''
|
||||
const lastInitial = user.value.last_name?.charAt(0) || ''
|
||||
return (firstInitial + lastInitial).toUpperCase()
|
||||
})
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
// Use uploaded avatar if available
|
||||
if (user.value?.avatar_url) {
|
||||
// Use direct static file serving
|
||||
const cleanUrl = user.value.avatar_url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `/${cleanUrl}`
|
||||
}
|
||||
// Return empty string to show fallback initials
|
||||
return ''
|
||||
})
|
||||
|
||||
// Role-based features
|
||||
const isAdminOrCoordinator = computed(() => {
|
||||
return user.value?.is_admin || user.value?.role === 'coordinator'
|
||||
})
|
||||
|
||||
const showRoleFeatures = computed(() => {
|
||||
return user.value?.role === 'developer' || isAdminOrCoordinator.value || user.value?.is_admin
|
||||
})
|
||||
|
||||
// Notifications state (this would typically come from a notifications store)
|
||||
const notificationsEnabled = ref(true)
|
||||
|
||||
// Actions
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNotifications = () => {
|
||||
notificationsEnabled.value = !notificationsEnabled.value
|
||||
// In a real app, this would update user preferences
|
||||
console.log('Notifications toggled:', notificationsEnabled.value)
|
||||
}
|
||||
|
||||
const showKeyboardShortcuts = () => {
|
||||
// In a real app, this would open a modal with keyboard shortcuts
|
||||
console.log('Keyboard shortcuts modal would open here')
|
||||
// For now, just show an alert with some common shortcuts
|
||||
alert(`Keyboard Shortcuts:
|
||||
|
||||
⌘/Ctrl + B - Toggle sidebar
|
||||
⌘/Ctrl + K - Quick search
|
||||
⌘/Ctrl + , - Open preferences
|
||||
⌘/Ctrl + / - Show help
|
||||
|
||||
More shortcuts available in the help documentation.`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<Card v-if="departmentSpec" class="border-primary/20 bg-primary/5">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="getDepartmentIcon(department)" class="h-5 w-5 text-primary" />
|
||||
<CardTitle class="text-base text-primary">
|
||||
{{ formatDepartmentName(department) }} Delivery Requirements
|
||||
</CardTitle>
|
||||
<Badge variant="outline" class="ml-auto">Your Department</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Quick Reference Grid -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="text-center p-3 bg-background/50 rounded-lg">
|
||||
<Monitor class="h-4 w-4 mx-auto mb-1 text-muted-foreground" />
|
||||
<Label class="text-xs text-muted-foreground block">Resolution</Label>
|
||||
<p class="font-semibold text-sm">{{ departmentSpec.resolution }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-3 bg-background/50 rounded-lg">
|
||||
<FileVideo class="h-4 w-4 mx-auto mb-1 text-muted-foreground" />
|
||||
<Label class="text-xs text-muted-foreground block">Format</Label>
|
||||
<p class="font-semibold text-sm uppercase">{{ departmentSpec.format }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-3 bg-background/50 rounded-lg">
|
||||
<Settings class="h-4 w-4 mx-auto mb-1 text-muted-foreground" />
|
||||
<Label class="text-xs text-muted-foreground block">Codec</Label>
|
||||
<p class="font-semibold text-sm">{{ departmentSpec.codec ? departmentSpec.codec.toUpperCase() : 'None' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-3 bg-background/50 rounded-lg">
|
||||
<Star class="h-4 w-4 mx-auto mb-1 text-muted-foreground" />
|
||||
<Label class="text-xs text-muted-foreground block">Quality</Label>
|
||||
<p class="font-semibold text-sm capitalize">{{ departmentSpec.quality || 'Default' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Project Info -->
|
||||
<div v-if="projectSpecs" class="pt-3 border-t border-primary/20">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-muted-foreground">Frame Rate:</span>
|
||||
<span class="font-medium">{{ projectSpecs.frame_rate ? getFrameRateLabel(projectSpecs.frame_rate) : 'Not set' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Image class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-muted-foreground">Image Resolution:</span>
|
||||
<span class="font-medium">{{ projectSpecs.delivery_image_resolution || 'Not set' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="copySpecsToClipboard"
|
||||
class="flex-1"
|
||||
>
|
||||
<Copy class="h-3 w-3 mr-2" />
|
||||
Copy Specs
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="projectSpecs?.data_drive_path"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="copyToClipboard(projectSpecs.data_drive_path)"
|
||||
class="flex-1"
|
||||
>
|
||||
<FolderOpen class="h-3 w-3 mr-2" />
|
||||
Copy Data Path
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- No Department Assigned -->
|
||||
<Card v-else-if="!department" class="border-muted">
|
||||
<CardContent class="text-center py-6">
|
||||
<AlertCircle class="h-8 w-8 mx-auto text-muted-foreground mb-3" />
|
||||
<p class="text-muted-foreground">No department role assigned</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">Contact your coordinator to assign a department role</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- No Specs for Department -->
|
||||
<Card v-else class="border-muted">
|
||||
<CardContent class="text-center py-6">
|
||||
<Settings class="h-8 w-8 mx-auto text-muted-foreground mb-3" />
|
||||
<p class="text-muted-foreground">No delivery requirements configured for {{ formatDepartmentName(department) }}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">Contact your coordinator to configure department specifications</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Monitor, FileVideo, Settings, Star, Clock, Image, Copy, FolderOpen, AlertCircle,
|
||||
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import type { ProjectTechnicalSpecs, DeliveryMovieSpec } from '@/services/project'
|
||||
|
||||
interface Props {
|
||||
department?: string
|
||||
projectSpecs?: ProjectTechnicalSpecs
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { toast } = useToast()
|
||||
|
||||
// Computed properties
|
||||
const departmentSpec = computed((): DeliveryMovieSpec | undefined => {
|
||||
if (!props.department || !props.projectSpecs?.delivery_movie_specs_by_department) {
|
||||
return undefined
|
||||
}
|
||||
return props.projectSpecs.delivery_movie_specs_by_department[props.department]
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatDepartmentName = (department: string) => {
|
||||
return department.charAt(0).toUpperCase() + department.slice(1)
|
||||
}
|
||||
|
||||
const getDepartmentIcon = (department: string) => {
|
||||
const icons: Record<string, any> = {
|
||||
layout: Layers,
|
||||
animation: Zap,
|
||||
lighting: Lightbulb,
|
||||
composite: Palette,
|
||||
modeling: Box,
|
||||
rigging: Wrench,
|
||||
surfacing: Paintbrush
|
||||
}
|
||||
return icons[department] || Box
|
||||
}
|
||||
|
||||
const getFrameRateLabel = (frameRate: number) => {
|
||||
const labels: Record<number, string> = {
|
||||
23.976: "23.976 fps (Cinema)",
|
||||
24.0: "24 fps (Cinema)",
|
||||
25.0: "25 fps (PAL)",
|
||||
29.97: "29.97 fps (NTSC)",
|
||||
30.0: "30 fps",
|
||||
50.0: "50 fps (PAL High)",
|
||||
59.94: "59.94 fps (NTSC High)",
|
||||
60.0: "60 fps"
|
||||
}
|
||||
return labels[frameRate] || `${frameRate} fps`
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast({
|
||||
title: 'Copied',
|
||||
description: 'Text copied to clipboard'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to copy to clipboard',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const copySpecsToClipboard = async () => {
|
||||
if (!departmentSpec.value) return
|
||||
|
||||
const specsText = [
|
||||
`${formatDepartmentName(props.department!)} Delivery Requirements:`,
|
||||
`Resolution: ${departmentSpec.value.resolution}`,
|
||||
`Format: ${departmentSpec.value.format.toUpperCase()}`,
|
||||
`Codec: ${departmentSpec.value.codec ? departmentSpec.value.codec.toUpperCase() : 'None'}`,
|
||||
`Quality: ${departmentSpec.value.quality || 'Default'}`,
|
||||
props.projectSpecs?.frame_rate ? `Frame Rate: ${getFrameRateLabel(props.projectSpecs.frame_rate)}` : '',
|
||||
props.projectSpecs?.delivery_image_resolution ? `Image Resolution: ${props.projectSpecs.delivery_image_resolution}` : ''
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
await copyToClipboard(specsText)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Project Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="Enter project name"
|
||||
:disabled="isSubmitting"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="code_name">Code Name</Label>
|
||||
<Input
|
||||
id="code_name"
|
||||
v-model="formData.code_name"
|
||||
placeholder="Enter project code name"
|
||||
:disabled="isSubmitting"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="client_name">Client Name</Label>
|
||||
<Input
|
||||
id="client_name"
|
||||
v-model="formData.client_name"
|
||||
placeholder="Enter client name"
|
||||
:disabled="isSubmitting"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="project_type">Project Type</Label>
|
||||
<Select v-model="formData.project_type" :disabled="isSubmitting">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select project type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tv">TV</SelectItem>
|
||||
<SelectItem value="cinema">Cinema</SelectItem>
|
||||
<SelectItem value="game">Game</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
placeholder="Enter project description (optional)"
|
||||
:disabled="isSubmitting"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status and Dates -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="status">Status</Label>
|
||||
<Select v-model="formData.status" :disabled="isSubmitting">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="planning">Planning</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="on_hold">On Hold</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="start_date">Start Date</Label>
|
||||
<Input
|
||||
id="start_date"
|
||||
v-model="formData.start_date"
|
||||
type="date"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="end_date">End Date</Label>
|
||||
<Input
|
||||
id="end_date"
|
||||
v-model="formData.end_date"
|
||||
type="date"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="resetForm"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isSubmitting || !hasChanges"
|
||||
>
|
||||
<div v-if="isSubmitting" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
<span v-else>Save Changes</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<div v-if="successMessage" class="flex items-center gap-2 text-green-600 text-sm">
|
||||
<CheckCircle class="h-4 w-4" />
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="flex items-center gap-2 text-red-600 text-sm">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { CheckCircle, AlertCircle } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { projectService, type Project, type ProjectUpdate } from '@/services/project'
|
||||
|
||||
interface Props {
|
||||
project: Project
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'project-updated', project: Project): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// State
|
||||
const isSubmitting = ref(false)
|
||||
const successMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
// Form data
|
||||
const formData = reactive<ProjectUpdate & { name: string; code_name: string; client_name: string; project_type: 'tv' | 'cinema' | 'game' }>({
|
||||
name: props.project.name,
|
||||
code_name: props.project.code_name,
|
||||
client_name: props.project.client_name,
|
||||
project_type: props.project.project_type,
|
||||
description: props.project.description || '',
|
||||
status: props.project.status,
|
||||
start_date: props.project.start_date || '',
|
||||
end_date: props.project.end_date || ''
|
||||
})
|
||||
|
||||
// Original data for comparison
|
||||
const originalData = reactive({
|
||||
name: props.project.name,
|
||||
code_name: props.project.code_name,
|
||||
client_name: props.project.client_name,
|
||||
project_type: props.project.project_type,
|
||||
description: props.project.description || '',
|
||||
status: props.project.status,
|
||||
start_date: props.project.start_date || '',
|
||||
end_date: props.project.end_date || ''
|
||||
})
|
||||
|
||||
// Computed
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(formData).some(key => {
|
||||
const formValue = formData[key as keyof typeof formData]
|
||||
const originalValue = originalData[key as keyof typeof originalData]
|
||||
return formValue !== originalValue
|
||||
})
|
||||
})
|
||||
|
||||
// Methods
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, originalData)
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
successMessage.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!hasChanges.value) return
|
||||
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
clearMessages()
|
||||
|
||||
// Prepare update data (only include changed fields)
|
||||
const updateData: ProjectUpdate = {}
|
||||
Object.keys(formData).forEach(key => {
|
||||
const formValue = formData[key as keyof typeof formData]
|
||||
const originalValue = originalData[key as keyof typeof originalData]
|
||||
if (formValue !== originalValue) {
|
||||
updateData[key as keyof ProjectUpdate] = formValue as any
|
||||
}
|
||||
})
|
||||
|
||||
// Update project
|
||||
const updatedProject = await projectService.updateProject(props.project.id, updateData)
|
||||
|
||||
// Update original data to reflect the new state
|
||||
Object.assign(originalData, formData)
|
||||
|
||||
// Emit the updated project
|
||||
emit('project-updated', updatedProject)
|
||||
|
||||
successMessage.value = 'Project updated successfully!'
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
successMessage.value = ''
|
||||
}, 3000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update project:', error)
|
||||
errorMessage.value = error instanceof Error ? error.message : 'Failed to update project'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for prop changes to update form data
|
||||
watch(() => props.project, (newProject) => {
|
||||
Object.assign(formData, {
|
||||
name: newProject.name,
|
||||
code_name: newProject.code_name,
|
||||
client_name: newProject.client_name,
|
||||
project_type: newProject.project_type,
|
||||
description: newProject.description || '',
|
||||
status: newProject.status,
|
||||
start_date: newProject.start_date || '',
|
||||
end_date: newProject.end_date || ''
|
||||
})
|
||||
|
||||
Object.assign(originalData, {
|
||||
name: newProject.name,
|
||||
code_name: newProject.code_name,
|
||||
client_name: newProject.client_name,
|
||||
project_type: newProject.project_type,
|
||||
description: newProject.description || '',
|
||||
status: newProject.status,
|
||||
start_date: newProject.start_date || '',
|
||||
end_date: newProject.end_date || ''
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
// Clear messages when form data changes
|
||||
watch(formData, clearMessages)
|
||||
</script>
|
||||
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Team Members</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Manage project team members and their department roles
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="openAddDialog" size="sm">
|
||||
<UserPlus class="h-4 w-4 mr-2" />
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">Loading members...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<AlertCircle class="h-8 w-8 mx-auto text-destructive mb-2" />
|
||||
<p class="text-sm text-muted-foreground">{{ error }}</p>
|
||||
<Button @click="loadMembers" variant="outline" size="sm" class="mt-2">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div v-else-if="members.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage
|
||||
v-if="member.user_id"
|
||||
:src="getAvatarUrl(member.user_id)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${member.user_first_name} ${member.user_last_name}`"
|
||||
/>
|
||||
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ member.user_email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Department Role -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm">Department:</Label>
|
||||
<select
|
||||
:value="member.department_role || 'none'"
|
||||
@change="(event) => updateMemberRole(member.id, (event.target as HTMLSelectElement).value === 'none' ? null : (event.target as HTMLSelectElement).value)"
|
||||
:disabled="isUpdatingMember === member.id"
|
||||
class="flex h-8 w-32 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="layout">Layout</option>
|
||||
<option value="animation">Animation</option>
|
||||
<option value="lighting">Lighting</option>
|
||||
<option value="composite">Composite</option>
|
||||
<option value="modeling">Modeling</option>
|
||||
<option value="rigging">Rigging</option>
|
||||
<option value="surfacing">Surfacing</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Joined Date -->
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Joined {{ formatDate(member.joined_at) }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
@click="removeMember(member)"
|
||||
class="text-destructive focus:text-destructive"
|
||||
>
|
||||
<UserMinus class="h-4 w-4 mr-2" />
|
||||
Remove from Project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<Users class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground mb-4">No team members yet</p>
|
||||
<Button @click="openAddDialog" size="sm">
|
||||
<UserPlus class="h-4 w-4 mr-2" />
|
||||
Add First Member
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Add Member Modal -->
|
||||
<div v-if="showAddMemberDialog" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="closeAddDialog"
|
||||
></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold">Add Team Member</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Add a user to this project and assign their department role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- User Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label for="user">User</Label>
|
||||
<select
|
||||
v-model="newMember.userId"
|
||||
:disabled="isAddingMember"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select a user</option>
|
||||
<option
|
||||
v-for="user in (availableUsers || [])"
|
||||
:key="user.id"
|
||||
:value="user.id.toString()"
|
||||
>
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Department Role -->
|
||||
<div class="space-y-2">
|
||||
<Label for="department">Department Role (Optional)</Label>
|
||||
<select
|
||||
v-model="newMember.departmentRole"
|
||||
:disabled="isAddingMember"
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="layout">Layout</option>
|
||||
<option value="animation">Animation</option>
|
||||
<option value="lighting">Lighting</option>
|
||||
<option value="composite">Composite</option>
|
||||
<option value="modeling">Modeling</option>
|
||||
<option value="rigging">Rigging</option>
|
||||
<option value="surfacing">Surfacing</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="closeAddDialog"
|
||||
:disabled="isAddingMember"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="addMember"
|
||||
:disabled="!newMember.userId || isAddingMember"
|
||||
>
|
||||
<div v-if="isAddingMember" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
<span>Adding...</span>
|
||||
</div>
|
||||
<span v-else>Add Member</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Member Confirmation -->
|
||||
<div v-if="showRemoveDialog" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="showRemoveDialog = false"
|
||||
></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative bg-background rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold">Remove Team Member</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Are you sure you want to remove "{{ memberToRemove?.user_first_name }} {{ memberToRemove?.user_last_name }}" from this project?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="showRemoveDialog = false"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmRemoveMember"
|
||||
variant="destructive"
|
||||
>
|
||||
Remove Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
Users, UserPlus, UserMinus, MoreHorizontal, AlertCircle, RefreshCw
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { userService } from '@/services/user'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// State
|
||||
const members = ref<ProjectMember[]>([])
|
||||
const availableUsers = ref<User[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isUpdatingMember = ref<number | null>(null)
|
||||
const isAddingMember = ref(false)
|
||||
const showAddMemberDialog = ref(false)
|
||||
const showRemoveDialog = ref(false)
|
||||
const memberToRemove = ref<ProjectMember | null>(null)
|
||||
|
||||
const newMember = ref({
|
||||
userId: '',
|
||||
departmentRole: ''
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadMembers = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
members.value = await projectService.getProjectMembers(props.projectId)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load members'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAvailableUsers = async () => {
|
||||
try {
|
||||
const allUsers = await userService.getAllUsers()
|
||||
// Filter out users who are already members
|
||||
const memberUserIds = new Set(members.value.map(m => m.user_id))
|
||||
availableUsers.value = allUsers.filter(user =>
|
||||
user.is_approved && !memberUserIds.has(user.id)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to load users:', err)
|
||||
error.value = 'Failed to load available users'
|
||||
availableUsers.value = [] // Ensure it's always an array
|
||||
}
|
||||
}
|
||||
|
||||
const updateMemberRole = async (memberId: number, departmentRole: string | null) => {
|
||||
try {
|
||||
isUpdatingMember.value = memberId
|
||||
await projectService.updateProjectMember(props.projectId, memberId, {
|
||||
department_role: departmentRole as any
|
||||
})
|
||||
|
||||
// Update local state
|
||||
const member = members.value.find(m => m.id === memberId)
|
||||
if (member) {
|
||||
member.department_role = departmentRole as any
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to update member role'
|
||||
} finally {
|
||||
isUpdatingMember.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const addMember = async () => {
|
||||
if (!newMember.value.userId) return
|
||||
|
||||
try {
|
||||
isAddingMember.value = true
|
||||
const memberData = {
|
||||
user_id: parseInt(newMember.value.userId),
|
||||
department_role: newMember.value.departmentRole || undefined
|
||||
}
|
||||
|
||||
const addedMember = await projectService.addProjectMember(props.projectId, memberData)
|
||||
members.value.push(addedMember)
|
||||
|
||||
closeAddDialog()
|
||||
|
||||
// Refresh available users
|
||||
await loadAvailableUsers()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to add member'
|
||||
} finally {
|
||||
isAddingMember.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeMember = (member: ProjectMember) => {
|
||||
memberToRemove.value = member
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
const confirmRemoveMember = async () => {
|
||||
if (!memberToRemove.value) return
|
||||
|
||||
try {
|
||||
await projectService.removeProjectMember(props.projectId, memberToRemove.value.id)
|
||||
|
||||
// Remove from local state
|
||||
const index = members.value.findIndex(m => m.id === memberToRemove.value!.id)
|
||||
if (index !== -1) {
|
||||
members.value.splice(index, 1)
|
||||
}
|
||||
|
||||
showRemoveDialog.value = false
|
||||
memberToRemove.value = null
|
||||
|
||||
// Refresh available users
|
||||
await loadAvailableUsers()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to remove member'
|
||||
}
|
||||
}
|
||||
|
||||
const getUserInitials = (member: ProjectMember) => {
|
||||
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
const getAvatarUrl = (userIdOrUrl: string | number | null | undefined) => {
|
||||
if (!userIdOrUrl) return ''
|
||||
// If it's a number, we don't have the avatar URL, so we can't display avatars
|
||||
if (typeof userIdOrUrl === 'number') {
|
||||
return ''
|
||||
}
|
||||
// If it's already a full URL, return it
|
||||
if (userIdOrUrl.startsWith('http')) return userIdOrUrl
|
||||
// Check if it looks like a user ID (numeric string)
|
||||
if (/^\d+$/.test(userIdOrUrl)) {
|
||||
return ''
|
||||
}
|
||||
// Use direct static file serving for avatar URLs
|
||||
const cleanUrl = userIdOrUrl.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `/${cleanUrl}`
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const openAddDialog = async () => {
|
||||
try {
|
||||
await loadAvailableUsers()
|
||||
showAddMemberDialog.value = true
|
||||
} catch (err) {
|
||||
console.error('Failed to open add dialog:', err)
|
||||
error.value = 'Failed to load user list'
|
||||
}
|
||||
}
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddMemberDialog.value = false
|
||||
// Reset form
|
||||
newMember.value = { userId: '', departmentRole: '' }
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Add Member Section -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">Add Team Member</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Select
|
||||
:model-value="newMember.user_id"
|
||||
@update:model-value="newMember.user_id = $event"
|
||||
class="flex-1"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
:value="user.id.toString()"
|
||||
>
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.email }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
:model-value="newMember.department_role"
|
||||
@update:model-value="newMember.department_role = $event"
|
||||
class="w-40"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No Department</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
@click="addMember"
|
||||
:disabled="!newMember.user_id || isLoading"
|
||||
size="sm"
|
||||
>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Current Members -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">
|
||||
Current Members ({{ members.length }})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading && members.length === 0"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<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 members...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div v-else-if="members.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"
|
||||
>
|
||||
<User class="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-sm">
|
||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ member.user_email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
:model-value="member.department_role || ''"
|
||||
@update:model-value="(value) => updateMemberRole(member, value)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-8">
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No Department</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="removeMember(member)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<Users class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No members assigned to this project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end pt-4 border-t">
|
||||
<Button @click="$emit('close')" variant="outline"> Close </Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { Plus, User, Users, X } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
import { projectService, type ProjectMember } from "@/services/project";
|
||||
import { userService } from "@/services/user";
|
||||
import type { User as UserType } from "@/types/auth";
|
||||
import type { Project } from "@/stores/projects";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// State
|
||||
const members = ref<ProjectMember[]>([]);
|
||||
const allUsers = ref<UserType[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const newMember = ref({
|
||||
user_id: "",
|
||||
department_role: "",
|
||||
});
|
||||
|
||||
// Computed
|
||||
const availableUsers = computed(() => {
|
||||
const memberUserIds = new Set(members.value.map((m) => m.user_id));
|
||||
return allUsers.value.filter((user) => !memberUserIds.has(user.id));
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadMembers = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
members.value = await projectService.getProjectMembers(props.project.id);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load project members",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
allUsers.value = await userService.getUsers();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load users",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addMember = async () => {
|
||||
if (!newMember.value.user_id) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const memberData = {
|
||||
user_id: parseInt(newMember.value.user_id),
|
||||
department_role: newMember.value.department_role || undefined,
|
||||
};
|
||||
|
||||
const addedMember = await projectService.addProjectMember(
|
||||
props.project.id,
|
||||
memberData
|
||||
);
|
||||
members.value.push(addedMember);
|
||||
|
||||
// Reset form
|
||||
newMember.value = {
|
||||
user_id: "",
|
||||
department_role: "",
|
||||
};
|
||||
|
||||
toast({
|
||||
title: "Member added",
|
||||
description: "Team member has been added to the project",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Failed to add member",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateMemberRole = async (member: ProjectMember, newRole: string) => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const updatedMember = await projectService.updateProjectMember(
|
||||
props.project.id,
|
||||
member.id,
|
||||
{ department_role: newRole || undefined }
|
||||
);
|
||||
|
||||
const index = members.value.findIndex((m) => m.id === member.id);
|
||||
if (index !== -1) {
|
||||
members.value[index] = updatedMember;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Role updated",
|
||||
description: "Member department role has been updated",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update member role",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (member: ProjectMember) => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
await projectService.removeProjectMember(props.project.id, member.id);
|
||||
|
||||
const index = members.value.findIndex((m) => m.id === member.id);
|
||||
if (index !== -1) {
|
||||
members.value.splice(index, 1);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Member removed",
|
||||
description: "Team member has been removed from the project",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to remove member",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMembers();
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="grid w-full grid-cols-5 h-auto bg-muted/50 p-1 rounded-md">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="setActiveTab(tab.id)"
|
||||
:class="[
|
||||
'flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 py-2 px-1 sm:px-3 min-h-[3rem] sm:min-h-[2.5rem] transition-all duration-200 text-center rounded-sm',
|
||||
activeTab === tab.id
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<component :is="tab.icon" class="h-4 w-4 flex-shrink-0" />
|
||||
<span class="hidden sm:inline text-sm font-medium">{{ tab.label }}</span>
|
||||
<span class="sm:hidden text-xs font-medium">{{ getMobileLabel(tab) }}</span>
|
||||
</div>
|
||||
<Badge
|
||||
v-if="tab.count !== undefined"
|
||||
variant="secondary"
|
||||
class="text-xs hidden sm:inline-flex min-w-[1.5rem] h-5"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</Badge>
|
||||
<!-- Mobile count display -->
|
||||
<div
|
||||
v-if="tab.count !== undefined"
|
||||
class="sm:hidden text-xs text-muted-foreground font-medium"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { LayoutDashboard, Camera, Package, ListTodo, Settings } from "lucide-vue-next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
route: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
shotCount?: number;
|
||||
assetCount?: number;
|
||||
taskCount?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Define available tabs
|
||||
const tabs = computed<Tab[]>(() => [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Overview",
|
||||
icon: LayoutDashboard,
|
||||
route: `/projects/${props.projectId}`,
|
||||
},
|
||||
{
|
||||
id: "shots",
|
||||
label: "Shots",
|
||||
icon: Camera,
|
||||
route: `/projects/${props.projectId}/shots`,
|
||||
count: props.shotCount,
|
||||
},
|
||||
{
|
||||
id: "assets",
|
||||
label: "Assets",
|
||||
icon: Package,
|
||||
route: `/projects/${props.projectId}/assets`,
|
||||
count: props.assetCount,
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
label: "Tasks",
|
||||
icon: ListTodo,
|
||||
route: `/projects/${props.projectId}/tasks`,
|
||||
count: props.taskCount,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
icon: Settings,
|
||||
route: `/projects/${props.projectId}/settings`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Determine active tab based on current route
|
||||
const activeTab = computed(() => {
|
||||
const currentPath = route.path;
|
||||
|
||||
// Debug logging
|
||||
console.log('ProjectTabs - Current path:', currentPath);
|
||||
console.log('ProjectTabs - Project ID:', props.projectId);
|
||||
|
||||
if (currentPath === `/projects/${props.projectId}`) {
|
||||
console.log('ProjectTabs - Active tab: overview');
|
||||
return "overview";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/shots`)) {
|
||||
console.log('ProjectTabs - Active tab: shots');
|
||||
return "shots";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/assets`)) {
|
||||
console.log('ProjectTabs - Active tab: assets');
|
||||
return "assets";
|
||||
} else if (currentPath.startsWith(`/projects/${props.projectId}/tasks`)) {
|
||||
console.log('ProjectTabs - Active tab: tasks');
|
||||
return "tasks";
|
||||
} else if (
|
||||
currentPath.startsWith(`/projects/${props.projectId}/settings`)
|
||||
) {
|
||||
console.log('ProjectTabs - Active tab: settings');
|
||||
return "settings";
|
||||
}
|
||||
|
||||
console.log('ProjectTabs - Active tab: overview (default)');
|
||||
return "overview";
|
||||
});
|
||||
|
||||
// Set active tab and navigate
|
||||
const setActiveTab = (tabId: string) => {
|
||||
const tab = tabs.value.find((t) => t.id === tabId);
|
||||
console.log('ProjectTabs - Setting active tab:', tabId);
|
||||
console.log('ProjectTabs - Tab found:', tab);
|
||||
console.log('ProjectTabs - Current route path:', route.path);
|
||||
console.log('ProjectTabs - Target route:', tab?.route);
|
||||
|
||||
if (tab) {
|
||||
console.log('ProjectTabs - Navigating to:', tab.route);
|
||||
router.push(tab.route).catch(err => {
|
||||
console.error('ProjectTabs - Navigation error:', err);
|
||||
});
|
||||
} else {
|
||||
console.log('ProjectTabs - Tab not found');
|
||||
}
|
||||
};
|
||||
|
||||
// Get mobile label for tabs
|
||||
const getMobileLabel = (tab: Tab) => {
|
||||
switch (tab.id) {
|
||||
case "settings":
|
||||
return "Settings";
|
||||
case "overview":
|
||||
return "Info";
|
||||
case "shots":
|
||||
return "Shots";
|
||||
case "assets":
|
||||
return "Assets";
|
||||
case "tasks":
|
||||
return "Tasks";
|
||||
default:
|
||||
return tab.label.charAt(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for route changes to ensure tab state persistence
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
// Tab state is automatically updated via activeTab computed property
|
||||
// This ensures tab state persistence during project navigation
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-6">
|
||||
<!-- Current Thumbnail Display -->
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="w-48 h-32 rounded-lg overflow-hidden bg-muted flex items-center justify-center border-2 border-border"
|
||||
>
|
||||
<img
|
||||
v-if="thumbnailBlobUrl"
|
||||
:src="getThumbnailUrl()"
|
||||
alt="Project Thumbnail"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center text-center p-4"
|
||||
>
|
||||
<ImageIcon class="h-12 w-12 text-muted-foreground mb-2" />
|
||||
<span class="text-sm text-muted-foreground">{{ projectInitials }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Button Overlay -->
|
||||
<Button
|
||||
v-if="!currentThumbnailUrl"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="absolute bottom-2 right-2 h-8 w-8 rounded-full shadow-md"
|
||||
@click="triggerFileInput"
|
||||
:disabled="isUploading"
|
||||
>
|
||||
<Camera class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Hidden File Input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail Actions -->
|
||||
<div class="flex-1 space-y-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium mb-1">Project Thumbnail</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Upload a custom image to identify this project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="triggerFileInput"
|
||||
:disabled="isUploading"
|
||||
>
|
||||
<Upload class="h-4 w-4 mr-2" />
|
||||
{{ currentThumbnailUrl ? 'Replace' : 'Upload' }} Thumbnail
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="currentThumbnailUrl"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="handleRemoveThumbnail"
|
||||
:disabled="isRemoving || isUploading"
|
||||
>
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
JPG, PNG, GIF or WEBP (max 10MB)
|
||||
</p>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<div v-if="isUploading" class="flex items-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span class="text-xs">Uploading and processing...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p v-if="errorMessage" class="text-xs text-destructive">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag and Drop Area (when no thumbnail) -->
|
||||
<div
|
||||
v-if="!currentThumbnailUrl"
|
||||
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
|
||||
:class="{
|
||||
'border-primary bg-primary/5': isDragging,
|
||||
'border-border hover:border-primary/50': !isDragging
|
||||
}"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<ImageIcon class="h-10 w-10 mx-auto text-muted-foreground mb-3" />
|
||||
<p class="text-sm font-medium mb-1">
|
||||
Drag and drop your thumbnail here
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
or click the button above to browse
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Recommended: 800x600px or similar aspect ratio
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Upload, X, Loader2, Camera, ImageIcon } from 'lucide-vue-next'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService } from '@/services/project'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
currentThumbnailUrl?: string | null
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'thumbnail-updated', thumbnailUrl: string): void
|
||||
(e: 'thumbnail-removed'): void
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const isUploading = ref(false)
|
||||
const isRemoving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isDragging = ref(false)
|
||||
const thumbnailBlobUrl = ref<string | null>(null)
|
||||
|
||||
const projectInitials = computed(() => {
|
||||
if (!props.projectName) return '?'
|
||||
const words = props.projectName.split(' ')
|
||||
if (words.length >= 2) {
|
||||
return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase()
|
||||
}
|
||||
return props.projectName.substring(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const getThumbnailUrl = () => {
|
||||
return thumbnailBlobUrl.value || ''
|
||||
}
|
||||
|
||||
async function loadThumbnail() {
|
||||
if (!props.currentThumbnailUrl) {
|
||||
thumbnailBlobUrl.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(props.currentThumbnailUrl, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
// Revoke old blob URL if it exists
|
||||
if (thumbnailBlobUrl.value) {
|
||||
URL.revokeObjectURL(thumbnailBlobUrl.value)
|
||||
}
|
||||
|
||||
// Create new blob URL
|
||||
thumbnailBlobUrl.value = URL.createObjectURL(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load thumbnail:', error)
|
||||
thumbnailBlobUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const validateFile = (file: File): boolean => {
|
||||
errorMessage.value = ''
|
||||
|
||||
// Check file type
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
errorMessage.value = 'Invalid file format. Please upload JPG, PNG, GIF, or WEBP.'
|
||||
return false
|
||||
}
|
||||
|
||||
// Check file size (10MB max)
|
||||
const maxSize = 10 * 1024 * 1024 // 10MB in bytes
|
||||
if (file.size > maxSize) {
|
||||
errorMessage.value = 'File too large. Maximum size is 10MB.'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
if (!validateFile(file)) {
|
||||
toast({
|
||||
title: 'Invalid File',
|
||||
description: errorMessage.value,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await projectService.uploadThumbnail(props.projectId, file)
|
||||
|
||||
emit('thumbnail-updated', response.thumbnail_url)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Thumbnail uploaded successfully',
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.response?.data?.detail || 'Failed to upload thumbnail'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage.value,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
// Reset file input
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
||||
if (!file) return
|
||||
|
||||
await uploadFile(file)
|
||||
}
|
||||
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
isDragging.value = false
|
||||
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (!file) return
|
||||
|
||||
await uploadFile(file)
|
||||
}
|
||||
|
||||
const handleRemoveThumbnail = async () => {
|
||||
if (!confirm('Are you sure you want to remove the project thumbnail?')) {
|
||||
return
|
||||
}
|
||||
|
||||
isRemoving.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await projectService.deleteThumbnail(props.projectId)
|
||||
|
||||
emit('thumbnail-removed')
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Thumbnail removed successfully',
|
||||
})
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.response?.data?.detail || 'Failed to remove thumbnail'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage.value,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isRemoving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadThumbnail()
|
||||
})
|
||||
|
||||
watch(() => props.currentThumbnailUrl, () => {
|
||||
loadThumbnail()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up blob URL to prevent memory leaks
|
||||
if (thumbnailBlobUrl.value) {
|
||||
URL.revokeObjectURL(thumbnailBlobUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Table Header Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ episodeId ? `Episode ${episodeId} Shots` : "All Shots" }}
|
||||
</h3>
|
||||
<Badge variant="secondary" v-if="shots.length > 0">
|
||||
{{ shots.length }} shot{{ shots.length !== 1 ? "s" : "" }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="refreshShots">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" @click="createShot" v-if="episodeId">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Shot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<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 shots...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<AlertCircle class="h-8 w-8 mx-auto text-destructive mb-2" />
|
||||
<p class="text-muted-foreground">{{ error }}</p>
|
||||
<Button variant="outline" size="sm" @click="refreshShots" class="mt-2">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="shots.length === 0" class="text-center py-12">
|
||||
<Camera class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 class="text-lg font-semibold mb-2">No shots found</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{{
|
||||
episodeId
|
||||
? "This episode doesn't have any shots yet."
|
||||
: "No shots found for the selected criteria."
|
||||
}}
|
||||
</p>
|
||||
<Button @click="createShot" v-if="episodeId">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create First Shot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Shots Table -->
|
||||
<div v-else class="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Shot Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Frames</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Tasks</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead class="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="shot in shots"
|
||||
:key="shot.id"
|
||||
class="hover:bg-muted/50"
|
||||
>
|
||||
<TableCell class="font-medium">{{ shot.name }}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
v-if="shot.description"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ shot.description }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-muted-foreground italic"
|
||||
>No description</span
|
||||
>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="font-mono text-sm">
|
||||
{{ shot.frame_start }}-{{ shot.frame_end }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground ml-2">
|
||||
({{ shot.frame_end - shot.frame_start + 1 }} frames)
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getStatusVariant(shot.status)">
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm">{{ shot.task_count }} tasks</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(shot.updated_at) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="editShot(shot)">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="viewTasks(shot)">
|
||||
<CheckSquare class="h-4 w-4 mr-2" />
|
||||
View Tasks
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click="deleteShot(shot)"
|
||||
class="text-destructive"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import {
|
||||
Camera,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
CheckSquare,
|
||||
Trash2,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { shotService, type Shot, ShotStatus } from "@/services/shot";
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
episodeId?: number | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Reactive state
|
||||
const shots = ref<Shot[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Methods
|
||||
const loadShots = async () => {
|
||||
if (!props.projectId) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const shotsData = await shotService.getShots(
|
||||
props.projectId,
|
||||
props.episodeId || undefined
|
||||
);
|
||||
shots.value = shotsData;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to load shots";
|
||||
shots.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshShots = () => {
|
||||
loadShots();
|
||||
};
|
||||
|
||||
const createShot = () => {
|
||||
// TODO: Implement shot creation dialog
|
||||
console.log("Create shot for episode:", props.episodeId);
|
||||
};
|
||||
|
||||
const editShot = (shot: Shot) => {
|
||||
// TODO: Implement shot editing
|
||||
console.log("Edit shot:", shot);
|
||||
};
|
||||
|
||||
const viewTasks = (shot: Shot) => {
|
||||
// TODO: Navigate to shot tasks view
|
||||
console.log("View tasks for shot:", shot);
|
||||
};
|
||||
|
||||
const deleteShot = async (shot: Shot) => {
|
||||
// TODO: Implement shot deletion with confirmation
|
||||
console.log("Delete shot:", shot);
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return "secondary";
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return "default";
|
||||
case ShotStatus.ON_HOLD:
|
||||
return "outline";
|
||||
case ShotStatus.COMPLETED:
|
||||
return "default";
|
||||
case ShotStatus.APPROVED:
|
||||
return "default";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
};
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => [props.projectId, props.episodeId],
|
||||
() => {
|
||||
loadShots();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadShots();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardContent v-if="specs" class="space-y-6 pt-6">
|
||||
<!-- Basic Settings -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium text-muted-foreground">Frame Rate</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="font-medium">{{ specs.frame_rate ? getFrameRateLabel(specs.frame_rate) : 'Not set' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs font-medium text-muted-foreground">Delivery Image Resolution</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Monitor class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="font-medium">{{ specs.delivery_image_resolution || 'Not set' }}</span>
|
||||
<Badge v-if="specs.delivery_image_resolution" variant="outline" class="text-xs">
|
||||
{{ getResolutionLabel(specs.delivery_image_resolution) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Paths -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-sm">Storage Paths</h4>
|
||||
<div class="grid gap-3">
|
||||
<div class="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<HardDrive class="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<Label class="text-xs font-medium text-muted-foreground">Data Drive Path</Label>
|
||||
<p class="font-mono text-sm break-all">{{ specs.data_drive_path || 'Not configured' }}</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="specs.data_drive_path"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="copyToClipboard(specs.data_drive_path)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<Upload class="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<Label class="text-xs font-medium text-muted-foreground">Publish Storage Path</Label>
|
||||
<p class="font-mono text-sm break-all">{{ specs.publish_storage_path || 'Not configured' }}</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="specs.publish_storage_path"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="copyToClipboard(specs.publish_storage_path)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Movie Specifications -->
|
||||
<div class="space-y-3" v-if="hasMovieSpecs">
|
||||
<h4 class="font-medium text-sm">Delivery Movie Specifications</h4>
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="(spec, department) in specs.delivery_movie_specs_by_department"
|
||||
:key="department"
|
||||
class="border rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="getDepartmentIcon(department)" class="h-4 w-4" />
|
||||
<h5 class="font-medium capitalize">{{ formatDepartmentName(department) }}</h5>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-xs">{{ department }}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Resolution</Label>
|
||||
<p class="font-medium">{{ spec.resolution }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Format</Label>
|
||||
<p class="font-medium uppercase">{{ spec.format }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Codec</Label>
|
||||
<p class="font-medium">{{ spec.codec ? spec.codec.toUpperCase() : 'None' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Quality</Label>
|
||||
<p class="font-medium capitalize">{{ spec.quality || 'Default' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Department-Specific View for Artists -->
|
||||
<DepartmentSpecsPanel
|
||||
v-if="userDepartment"
|
||||
:department="userDepartment"
|
||||
:project-specs="specs"
|
||||
class="mt-6"
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent v-else class="text-center py-8">
|
||||
<Settings class="h-8 w-8 mx-auto text-muted-foreground mb-3" />
|
||||
<p class="text-muted-foreground">No technical specifications configured</p>
|
||||
<Button
|
||||
v-if="canEdit"
|
||||
@click="$emit('edit')"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3"
|
||||
>
|
||||
Configure Specifications
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Clock, Monitor, HardDrive, Upload, Copy, Edit, Settings, Star,
|
||||
Palette, Zap, Lightbulb, Layers, Box, Wrench, Paintbrush
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ProjectTechnicalSpecs, DeliveryMovieSpec } from '@/services/project'
|
||||
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
|
||||
|
||||
interface Props {
|
||||
specs?: ProjectTechnicalSpecs
|
||||
userDepartment?: string
|
||||
canEdit?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Computed properties
|
||||
const hasMovieSpecs = computed(() => {
|
||||
return props.specs?.delivery_movie_specs_by_department &&
|
||||
Object.keys(props.specs.delivery_movie_specs_by_department).length > 0
|
||||
})
|
||||
|
||||
const departmentSpec = computed((): DeliveryMovieSpec | undefined => {
|
||||
if (!props.userDepartment || !props.specs?.delivery_movie_specs_by_department) {
|
||||
return undefined
|
||||
}
|
||||
return props.specs.delivery_movie_specs_by_department[props.userDepartment]
|
||||
})
|
||||
|
||||
// Methods
|
||||
const getResolutionLabel = (resolution: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
"1280x720": "HD 720p",
|
||||
"1920x1080": "HD 1080p",
|
||||
"2048x1080": "2K DCI",
|
||||
"2560x1440": "QHD",
|
||||
"3840x2160": "4K UHD",
|
||||
"4096x2160": "4K DCI",
|
||||
"7680x4320": "8K UHD"
|
||||
}
|
||||
return labels[resolution] || resolution
|
||||
}
|
||||
|
||||
const getFrameRateLabel = (frameRate: number) => {
|
||||
const labels: Record<number, string> = {
|
||||
23.976: "23.976 fps (Cinema)",
|
||||
24.0: "24 fps (Cinema)",
|
||||
25.0: "25 fps (PAL)",
|
||||
29.97: "29.97 fps (NTSC)",
|
||||
30.0: "30 fps",
|
||||
50.0: "50 fps (PAL High)",
|
||||
59.94: "59.94 fps (NTSC High)",
|
||||
60.0: "60 fps"
|
||||
}
|
||||
return labels[frameRate] || `${frameRate} fps`
|
||||
}
|
||||
|
||||
const formatDepartmentName = (department: string) => {
|
||||
return department.charAt(0).toUpperCase() + department.slice(1)
|
||||
}
|
||||
|
||||
const getDepartmentIcon = (department: string) => {
|
||||
const icons: Record<string, any> = {
|
||||
layout: Layers,
|
||||
animation: Zap,
|
||||
lighting: Lightbulb,
|
||||
composite: Palette,
|
||||
modeling: Box,
|
||||
rigging: Wrench,
|
||||
surfacing: Paintbrush
|
||||
}
|
||||
return icons[department] || Box
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast({
|
||||
title: 'Copied',
|
||||
description: 'Path copied to clipboard'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to copy to clipboard',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Technical Specifications</h3>
|
||||
<p class="text-sm text-muted-foreground">Configure project technical requirements and delivery standards</p>
|
||||
</div>
|
||||
<Button
|
||||
@click="setDefaults"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<Settings class="h-4 w-4 mr-2" />
|
||||
Set Defaults
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="onSubmit" class="space-y-6">
|
||||
<!-- Basic Technical Settings -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Basic Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- Frame Rate -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="frame_rate">Frame Rate (fps)</Label>
|
||||
<Select
|
||||
:model-value="formData.frame_rate?.toString()"
|
||||
@update:model-value="formData.frame_rate = parseFloat($event)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="rate in COMMON_FRAME_RATES"
|
||||
:key="rate"
|
||||
:value="rate.toString()"
|
||||
>
|
||||
{{ FRAME_RATE_LABELS[rate] || `${rate} fps` }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Image Resolution -->
|
||||
<div>
|
||||
<Label for="delivery_image_resolution">Delivery Image Resolution</Label>
|
||||
<Select
|
||||
:model-value="formData.delivery_image_resolution"
|
||||
@update:model-value="formData.delivery_image_resolution = $event"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select resolution" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="resolution in STANDARD_RESOLUTIONS"
|
||||
:key="resolution"
|
||||
:value="resolution"
|
||||
>
|
||||
{{ resolution }} ({{ getResolutionLabel(resolution) }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Storage Paths -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Storage Paths</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div>
|
||||
<Label for="data_drive_path">Data Drive Path</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="data_drive_path"
|
||||
v-model="formData.data_drive_path"
|
||||
placeholder="e.g., /mnt/projects/data or D:\Projects\Data"
|
||||
class="flex-1"
|
||||
:class="{ 'border-destructive': getPathValidationMessage(formData.data_drive_path) }"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" title="Browse for folder">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Physical path where project work files are stored</p>
|
||||
<p v-if="getPathValidationMessage(formData.data_drive_path)" class="text-xs text-destructive mt-1">
|
||||
{{ getPathValidationMessage(formData.data_drive_path) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="publish_storage_path">Publish Storage Path</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="publish_storage_path"
|
||||
v-model="formData.publish_storage_path"
|
||||
placeholder="e.g., /mnt/projects/publish or D:\Projects\Publish"
|
||||
class="flex-1"
|
||||
:class="{ 'border-destructive': getPathValidationMessage(formData.publish_storage_path) }"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" title="Browse for folder">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Path where approved work is delivered</p>
|
||||
<p v-if="getPathValidationMessage(formData.publish_storage_path)" class="text-xs text-destructive mt-1">
|
||||
{{ getPathValidationMessage(formData.publish_storage_path) }}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Delivery Movie Specifications -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Delivery Movie Specifications by Department</CardTitle>
|
||||
<p class="text-sm text-muted-foreground">Configure movie delivery requirements for each department</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="department in DEPARTMENTS"
|
||||
:key="department"
|
||||
class="border rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium capitalize">{{ department }}</h4>
|
||||
<Badge variant="outline">{{ formatDepartmentName(department) }}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<!-- Resolution -->
|
||||
<div>
|
||||
<Label :for="`${department}_resolution`" class="text-xs">Resolution</Label>
|
||||
<Select
|
||||
:model-value="getMovieSpec(department).resolution"
|
||||
@update:model-value="updateMovieSpec(department, 'resolution', $event)"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Resolution" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="resolution in STANDARD_RESOLUTIONS"
|
||||
:key="resolution"
|
||||
:value="resolution"
|
||||
>
|
||||
{{ resolution }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Format -->
|
||||
<div>
|
||||
<Label :for="`${department}_format`" class="text-xs">Format</Label>
|
||||
<Select
|
||||
:model-value="getMovieSpec(department).format"
|
||||
@update:model-value="updateMovieSpec(department, 'format', $event)"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="format in MOVIE_FORMATS"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
{{ format.toUpperCase() }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Codec -->
|
||||
<div>
|
||||
<Label :for="`${department}_codec`" class="text-xs">Codec</Label>
|
||||
<Select
|
||||
:model-value="getMovieSpec(department).codec || 'none'"
|
||||
@update:model-value="updateMovieSpec(department, 'codec', $event === 'none' ? undefined : $event)"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Codec" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem
|
||||
v-for="codec in VIDEO_CODECS"
|
||||
:key="codec"
|
||||
:value="codec"
|
||||
>
|
||||
{{ codec.toUpperCase() }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Quality -->
|
||||
<div>
|
||||
<Label :for="`${department}_quality`" class="text-xs">Quality</Label>
|
||||
<Select
|
||||
:model-value="getMovieSpec(department).quality || 'default'"
|
||||
@update:model-value="updateMovieSpec(department, 'quality', $event === 'default' ? undefined : $event)"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Quality" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem
|
||||
v-for="quality in QUALITY_SETTINGS"
|
||||
:key="quality"
|
||||
:value="quality"
|
||||
>
|
||||
{{ quality.charAt(0).toUpperCase() + quality.slice(1) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" @click="onCancel">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" :disabled="isLoading">
|
||||
<div v-if="isLoading" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Save Technical Specifications
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Settings, FolderOpen } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type ProjectTechnicalSpecs, type DeliveryMovieSpec } from '@/services/project'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
initialSpecs?: ProjectTechnicalSpecs
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'saved', specs: ProjectTechnicalSpecs): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const { toast } = useToast()
|
||||
|
||||
// Constants - VFX Industry Standard Values
|
||||
const COMMON_FRAME_RATES = [23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 59.94, 60.0]
|
||||
const STANDARD_RESOLUTIONS = [
|
||||
"1280x720", // HD 720p
|
||||
"1920x1080", // HD 1080p
|
||||
"2048x1080", // 2K DCI
|
||||
"2560x1440", // QHD
|
||||
"3840x2160", // 4K UHD
|
||||
"4096x2160", // 4K DCI
|
||||
"7680x4320", // 8K UHD
|
||||
]
|
||||
const MOVIE_FORMATS = ["mov", "mp4", "exr", "dpx", "tiff", "ari", "r3d"]
|
||||
const VIDEO_CODECS = ["h264", "h265", "prores", "dnxhd", "dnxhr", "uncompressed", "avid", "cineform"]
|
||||
const QUALITY_SETTINGS = ["low", "medium", "high", "lossless"]
|
||||
const DEPARTMENTS = ["layout", "animation", "lighting", "composite", "modeling", "rigging", "surfacing"]
|
||||
|
||||
// Frame rate labels for better UX
|
||||
const FRAME_RATE_LABELS: Record<number, string> = {
|
||||
23.976: "23.976 fps (Cinema)",
|
||||
24.0: "24 fps (Cinema)",
|
||||
25.0: "25 fps (PAL)",
|
||||
29.97: "29.97 fps (NTSC)",
|
||||
30.0: "30 fps",
|
||||
50.0: "50 fps (PAL High)",
|
||||
59.94: "59.94 fps (NTSC High)",
|
||||
60.0: "60 fps"
|
||||
}
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const formData = reactive<ProjectTechnicalSpecs>({
|
||||
frame_rate: undefined,
|
||||
data_drive_path: '',
|
||||
publish_storage_path: '',
|
||||
delivery_image_resolution: '',
|
||||
delivery_movie_specs_by_department: {}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const getResolutionLabel = (resolution: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
"1280x720": "HD 720p",
|
||||
"1920x1080": "HD 1080p",
|
||||
"2048x1080": "2K DCI",
|
||||
"2560x1440": "QHD",
|
||||
"3840x2160": "4K UHD",
|
||||
"4096x2160": "4K DCI",
|
||||
"7680x4320": "8K UHD"
|
||||
}
|
||||
return labels[resolution] || resolution
|
||||
}
|
||||
|
||||
const validatePath = (path: string): boolean => {
|
||||
if (!path) return true // Optional field
|
||||
|
||||
// Basic path validation - check for valid characters and structure
|
||||
const windowsPathRegex = /^[a-zA-Z]:\\(?:[^<>:"|?*\r\n]+\\)*[^<>:"|?*\r\n]*$/
|
||||
const unixPathRegex = /^\/(?:[^/\0]+\/)*[^/\0]*$/
|
||||
const networkPathRegex = /^\\\\[^\\]+\\[^\\]+(?:\\[^\\]*)*$/
|
||||
|
||||
return windowsPathRegex.test(path) || unixPathRegex.test(path) || networkPathRegex.test(path)
|
||||
}
|
||||
|
||||
const getPathValidationMessage = (path: string): string => {
|
||||
if (!path) return ''
|
||||
if (!validatePath(path)) {
|
||||
return 'Please enter a valid file path (e.g., /mnt/projects or D:\\Projects)'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatDepartmentName = (department: string) => {
|
||||
return department.charAt(0).toUpperCase() + department.slice(1)
|
||||
}
|
||||
|
||||
const getMovieSpec = (department: string): DeliveryMovieSpec => {
|
||||
return formData.delivery_movie_specs_by_department?.[department] || {
|
||||
resolution: "1920x1080",
|
||||
format: "mov",
|
||||
codec: "h264",
|
||||
quality: "medium"
|
||||
}
|
||||
}
|
||||
|
||||
const updateMovieSpec = (department: string, field: keyof DeliveryMovieSpec, value: string | undefined) => {
|
||||
if (!formData.delivery_movie_specs_by_department) {
|
||||
formData.delivery_movie_specs_by_department = {}
|
||||
}
|
||||
|
||||
if (!formData.delivery_movie_specs_by_department[department]) {
|
||||
formData.delivery_movie_specs_by_department[department] = {
|
||||
resolution: "1920x1080",
|
||||
format: "mov",
|
||||
codec: "h264",
|
||||
quality: "medium"
|
||||
}
|
||||
}
|
||||
|
||||
formData.delivery_movie_specs_by_department[department][field] = value as any
|
||||
}
|
||||
|
||||
const loadSpecs = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const specs = await projectService.getProjectTechnicalSpecs(props.projectId)
|
||||
|
||||
// Update form data
|
||||
Object.assign(formData, {
|
||||
frame_rate: specs.frame_rate,
|
||||
data_drive_path: specs.data_drive_path || '',
|
||||
publish_storage_path: specs.publish_storage_path || '',
|
||||
delivery_image_resolution: specs.delivery_image_resolution || '',
|
||||
delivery_movie_specs_by_department: specs.delivery_movie_specs_by_department || {}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load technical specifications:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load technical specifications',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDefaults = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const specs = await projectService.setDefaultTechnicalSpecs(props.projectId)
|
||||
|
||||
// Update form data with defaults
|
||||
Object.assign(formData, specs)
|
||||
|
||||
toast({
|
||||
title: 'Defaults Applied',
|
||||
description: 'Default technical specifications have been applied'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to set default specifications:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to set default specifications',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// Validate paths before submitting
|
||||
const pathErrors = []
|
||||
if (formData.data_drive_path && !validatePath(formData.data_drive_path)) {
|
||||
pathErrors.push('Data drive path is invalid')
|
||||
}
|
||||
if (formData.publish_storage_path && !validatePath(formData.publish_storage_path)) {
|
||||
pathErrors.push('Publish storage path is invalid')
|
||||
}
|
||||
|
||||
if (pathErrors.length > 0) {
|
||||
toast({
|
||||
title: 'Validation Error',
|
||||
description: pathErrors.join(', '),
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up empty values
|
||||
const specsToSave: ProjectTechnicalSpecs = {
|
||||
frame_rate: formData.frame_rate,
|
||||
data_drive_path: formData.data_drive_path || undefined,
|
||||
publish_storage_path: formData.publish_storage_path || undefined,
|
||||
delivery_image_resolution: formData.delivery_image_resolution || undefined,
|
||||
delivery_movie_specs_by_department: formData.delivery_movie_specs_by_department
|
||||
}
|
||||
|
||||
const updatedSpecs = await projectService.updateProjectTechnicalSpecs(props.projectId, specsToSave)
|
||||
|
||||
toast({
|
||||
title: 'Technical Specifications Updated',
|
||||
description: 'Project technical specifications have been saved successfully'
|
||||
})
|
||||
|
||||
emit('saved', updatedSpecs)
|
||||
} catch (error) {
|
||||
console.error('Failed to save technical specifications:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to save technical specifications',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
// Initialize form data
|
||||
onMounted(() => {
|
||||
if (props.initialSpecs) {
|
||||
Object.assign(formData, props.initialSpecs)
|
||||
} else {
|
||||
loadSpecs()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Action Buttons -->
|
||||
<div v-if="canEdit" class="flex justify-end gap-2">
|
||||
<Button
|
||||
v-if="!isEditing"
|
||||
@click="startEditing"
|
||||
variant="outline"
|
||||
>
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit Specifications
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isEditing"
|
||||
@click="cancelEditing"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 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 technical specifications...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load specifications</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="loadSpecs" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<TechnicalSpecsForm
|
||||
v-else-if="isEditing"
|
||||
:project-id="projectId"
|
||||
:initial-specs="specs"
|
||||
@saved="onSpecsSaved"
|
||||
@cancel="cancelEditing"
|
||||
/>
|
||||
|
||||
<!-- Display Mode -->
|
||||
<TechnicalSpecsDisplay
|
||||
v-else
|
||||
:specs="specs"
|
||||
:user-department="userDepartment"
|
||||
:can-edit="canEdit"
|
||||
@edit="startEditing"
|
||||
/>
|
||||
|
||||
<!-- Notification System Integration -->
|
||||
<div v-if="showNotification" class="fixed bottom-4 right-4 z-50">
|
||||
<Card class="p-4 shadow-lg border-primary">
|
||||
<div class="flex items-center gap-3">
|
||||
<Bell class="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p class="font-medium">Technical Specifications Updated</p>
|
||||
<p class="text-sm text-muted-foreground">Project requirements have been updated</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="showNotification = false"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Edit, AlertCircle, RefreshCw, Bell, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
||||
import { notificationService } from '@/services/notifications'
|
||||
import TechnicalSpecsForm from './TechnicalSpecsForm.vue'
|
||||
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
userDepartment?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const specs = ref<ProjectTechnicalSpecs | undefined>()
|
||||
const isEditing = ref(false)
|
||||
const showNotification = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const canEdit = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadSpecs = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
specs.value = await projectService.getProjectTechnicalSpecs(props.projectId)
|
||||
} catch (err) {
|
||||
console.error('Failed to load technical specifications:', err)
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load technical specifications'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const onSpecsSaved = (updatedSpecs: ProjectTechnicalSpecs) => {
|
||||
specs.value = updatedSpecs
|
||||
isEditing.value = false
|
||||
|
||||
// Show notification
|
||||
showNotification.value = true
|
||||
setTimeout(() => {
|
||||
showNotification.value = false
|
||||
}, 5000)
|
||||
|
||||
// Notify project members through notification service
|
||||
notificationService.notifyProjectMembers(
|
||||
props.projectId,
|
||||
'Technical specifications have been updated. Please review the new requirements.'
|
||||
)
|
||||
|
||||
// Emit notification event for other components
|
||||
window.dispatchEvent(new CustomEvent('technical-specs-updated', {
|
||||
detail: { projectId: props.projectId, specs: updatedSpecs }
|
||||
}))
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadSpecs()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<Collapsible :open="isOpen" @update:open="isOpen = $event" class="w-full">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" class="flex w-full justify-between p-4 h-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="h-4 w-4" />
|
||||
<span class="font-medium">Technical Specifications</span>
|
||||
<Badge v-if="hasSpecs" variant="outline" class="ml-2">
|
||||
{{ specsCount }} configured
|
||||
</Badge>
|
||||
</div>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 transition-transform duration-200"
|
||||
:class="{ 'transform rotate-180': isOpen }"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent class="px-4 pb-4">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="text-center py-4">
|
||||
<AlertCircle class="h-6 w-6 mx-auto text-destructive mb-2" />
|
||||
<p class="text-sm text-muted-foreground">{{ error }}</p>
|
||||
<Button @click="loadSpecs" variant="outline" size="sm" class="mt-2">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<TechnicalSpecsDisplay
|
||||
:specs="specs"
|
||||
:user-department="userDepartment"
|
||||
:can-edit="canEdit"
|
||||
@edit="$emit('edit')"
|
||||
/>
|
||||
|
||||
<!-- Separate Department Panel for Better Visibility -->
|
||||
<DepartmentSpecsPanel
|
||||
v-if="userDepartment && specs"
|
||||
:department="userDepartment"
|
||||
:project-specs="specs"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Settings, ChevronDown, AlertCircle, RefreshCw } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { projectService, type ProjectTechnicalSpecs } from '@/services/project'
|
||||
import TechnicalSpecsDisplay from './TechnicalSpecsDisplay.vue'
|
||||
import DepartmentSpecsPanel from './DepartmentSpecsPanel.vue'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
userDepartment?: string
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// State
|
||||
const isOpen = ref(props.defaultOpen || false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const specs = ref<ProjectTechnicalSpecs | undefined>()
|
||||
|
||||
// Computed properties
|
||||
const canEdit = computed(() => {
|
||||
const userRole = authStore.userRole
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
return userRole === 'coordinator' || isAdmin
|
||||
})
|
||||
|
||||
const hasSpecs = computed(() => {
|
||||
if (!specs.value) return false
|
||||
|
||||
const hasBasicSpecs = !!(
|
||||
specs.value.frame_rate ||
|
||||
specs.value.data_drive_path ||
|
||||
specs.value.publish_storage_path ||
|
||||
specs.value.delivery_image_resolution
|
||||
)
|
||||
|
||||
const hasMovieSpecs = !!(
|
||||
specs.value.delivery_movie_specs_by_department &&
|
||||
Object.keys(specs.value.delivery_movie_specs_by_department).length > 0
|
||||
)
|
||||
|
||||
return hasBasicSpecs || hasMovieSpecs
|
||||
})
|
||||
|
||||
const specsCount = computed(() => {
|
||||
if (!specs.value) return 0
|
||||
|
||||
let count = 0
|
||||
if (specs.value.frame_rate) count++
|
||||
if (specs.value.data_drive_path) count++
|
||||
if (specs.value.publish_storage_path) count++
|
||||
if (specs.value.delivery_image_resolution) count++
|
||||
if (specs.value.delivery_movie_specs_by_department) {
|
||||
count += Object.keys(specs.value.delivery_movie_specs_by_department).length
|
||||
}
|
||||
|
||||
return count
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadSpecs = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
specs.value = await projectService.getProjectTechnicalSpecs(props.projectId)
|
||||
} catch (err) {
|
||||
console.error('Failed to load technical specifications:', err)
|
||||
error.value = 'Failed to load technical specifications'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load specs when component mounts or project changes
|
||||
onMounted(() => {
|
||||
loadSpecs()
|
||||
})
|
||||
|
||||
watch(() => props.projectId, () => {
|
||||
loadSpecs()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- Compact Technical Specs Display -->
|
||||
<div
|
||||
v-if="specs"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 text-sm"
|
||||
>
|
||||
<!-- Frame Rate -->
|
||||
<div
|
||||
v-if="specs.frame_rate"
|
||||
class="flex items-center gap-2 p-2 bg-muted/50 rounded"
|
||||
>
|
||||
<Clock class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-muted-foreground">Frame Rate:</span>
|
||||
<span class="font-medium">{{
|
||||
getFrameRateLabel(specs.frame_rate)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Image Resolution -->
|
||||
<div
|
||||
v-if="specs.delivery_image_resolution"
|
||||
class="flex items-center gap-2 p-2 bg-muted/50 rounded"
|
||||
>
|
||||
<Monitor class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-muted-foreground">Resolution:</span>
|
||||
<span class="font-medium">{{ specs.delivery_image_resolution }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Department Spec -->
|
||||
<div
|
||||
v-if="departmentSpec"
|
||||
class="flex items-center gap-2 p-2 bg-primary/10 rounded border border-primary/20"
|
||||
>
|
||||
<component
|
||||
:is="getDepartmentIcon(userDepartment)"
|
||||
class="h-4 w-4 text-primary"
|
||||
/>
|
||||
<span class="text-primary font-medium">{{
|
||||
departmentSpec.format.toUpperCase()
|
||||
}}</span>
|
||||
<span class="text-primary">{{ departmentSpec.resolution }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Department-Specific Requirements (Expanded) -->
|
||||
<Collapsible v-if="departmentSpec && showDepartmentDetails">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" class="w-full justify-between p-2 h-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="getDepartmentIcon(userDepartment)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="font-medium"
|
||||
>{{ formatDepartmentName(userDepartment) }} Requirements</span
|
||||
>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent class="px-2 pb-2">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs">
|
||||
<div class="text-center p-2 bg-background border rounded">
|
||||
<Label class="text-muted-foreground block">Resolution</Label>
|
||||
<p class="font-medium">{{ departmentSpec.resolution }}</p>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-background border rounded">
|
||||
<Label class="text-muted-foreground block">Format</Label>
|
||||
<p class="font-medium uppercase">{{ departmentSpec.format }}</p>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-background border rounded">
|
||||
<Label class="text-muted-foreground block">Codec</Label>
|
||||
<p class="font-medium">
|
||||
{{
|
||||
departmentSpec.codec
|
||||
? departmentSpec.codec.toUpperCase()
|
||||
: "None"
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-background border rounded">
|
||||
<Label class="text-muted-foreground block">Quality</Label>
|
||||
<p class="font-medium capitalize">
|
||||
{{ departmentSpec.quality || "Default" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Storage Paths (if needed) -->
|
||||
<div
|
||||
v-if="
|
||||
showPaths && (specs?.data_drive_path || specs?.publish_storage_path)
|
||||
"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="specs.data_drive_path"
|
||||
class="flex items-start gap-2 p-2 bg-muted/30 rounded text-xs"
|
||||
>
|
||||
<HardDrive class="h-3 w-3 text-muted-foreground mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<Label class="text-muted-foreground">Data Drive:</Label>
|
||||
<p class="font-mono break-all">{{ specs.data_drive_path }}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="copyToClipboard(specs.data_drive_path)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="specs.publish_storage_path"
|
||||
class="flex items-start gap-2 p-2 bg-muted/30 rounded text-xs"
|
||||
>
|
||||
<Upload class="h-3 w-3 text-muted-foreground mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<Label class="text-muted-foreground">Publish Path:</Label>
|
||||
<p class="font-mono break-all">{{ specs.publish_storage_path }}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="copyToClipboard(specs.publish_storage_path)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Specs Message -->
|
||||
<div v-if="!specs" class="text-center py-4 text-muted-foreground text-sm">
|
||||
<Settings class="h-6 w-6 mx-auto mb-2" />
|
||||
<p>No technical specifications configured</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
Clock,
|
||||
Monitor,
|
||||
HardDrive,
|
||||
Upload,
|
||||
Copy,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Palette,
|
||||
Zap,
|
||||
Lightbulb,
|
||||
Layers,
|
||||
Box,
|
||||
Wrench,
|
||||
Paintbrush,
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { useToast } from "@/components/ui/toast/use-toast";
|
||||
import type {
|
||||
ProjectTechnicalSpecs,
|
||||
DeliveryMovieSpec,
|
||||
} from "@/services/project";
|
||||
|
||||
interface Props {
|
||||
specs?: ProjectTechnicalSpecs;
|
||||
userDepartment?: string;
|
||||
showDepartmentDetails?: boolean;
|
||||
showPaths?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showDepartmentDetails: true,
|
||||
showPaths: false,
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Computed properties
|
||||
const departmentSpec = computed((): DeliveryMovieSpec | undefined => {
|
||||
if (
|
||||
!props.userDepartment ||
|
||||
!props.specs?.delivery_movie_specs_by_department
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return props.specs.delivery_movie_specs_by_department[props.userDepartment];
|
||||
});
|
||||
|
||||
// Methods
|
||||
const formatDepartmentName = (department?: string) => {
|
||||
if (!department) return "";
|
||||
return department.charAt(0).toUpperCase() + department.slice(1);
|
||||
};
|
||||
|
||||
const getDepartmentIcon = (department?: string) => {
|
||||
if (!department) return Box;
|
||||
|
||||
const icons: Record<string, any> = {
|
||||
layout: Layers,
|
||||
animation: Zap,
|
||||
lighting: Lightbulb,
|
||||
composite: Palette,
|
||||
modeling: Box,
|
||||
rigging: Wrench,
|
||||
surfacing: Paintbrush,
|
||||
};
|
||||
return icons[department] || Box;
|
||||
};
|
||||
|
||||
const getFrameRateLabel = (frameRate: number) => {
|
||||
const labels: Record<number, string> = {
|
||||
23.976: "23.976 fps (Cinema)",
|
||||
24.0: "24 fps (Cinema)",
|
||||
25.0: "25 fps (PAL)",
|
||||
29.97: "29.97 fps (NTSC)",
|
||||
30.0: "30 fps",
|
||||
50.0: "50 fps (PAL High)",
|
||||
59.94: "59.94 fps (NTSC High)",
|
||||
60.0: "60 fps",
|
||||
};
|
||||
return labels[frameRate] || `${frameRate} fps`;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({
|
||||
title: "Copied",
|
||||
description: "Path copied to clipboard",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to copy to clipboard",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<AlertDialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Custom Status</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the status "{{ status?.name }}"?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<!-- Task Count Warning -->
|
||||
<div v-if="taskCount > 0" class="space-y-4">
|
||||
<div class="p-4 border border-yellow-500 rounded-lg bg-yellow-50 dark:bg-yellow-950/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-500 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Status In Use
|
||||
</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
This status is currently used by {{ taskCount }} task{{ taskCount !== 1 ? 's' : '' }}.
|
||||
You must reassign {{ taskCount === 1 ? 'this task' : 'these tasks' }} to another status before deletion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reassignment Dropdown -->
|
||||
<div class="space-y-2">
|
||||
<Label for="reassign-status">Reassign tasks to:</Label>
|
||||
<Select v-model="selectedReassignStatus">
|
||||
<SelectTrigger id="reassign-status">
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<!-- System Statuses -->
|
||||
<SelectGroup>
|
||||
<SelectLabel>System Statuses</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="systemStatus in availableSystemStatuses"
|
||||
:key="systemStatus.id"
|
||||
:value="systemStatus.id"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full border"
|
||||
:style="{ backgroundColor: systemStatus.color, borderColor: systemStatus.color }"
|
||||
></div>
|
||||
<span class="capitalize">{{ formatStatusName(systemStatus.name) }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
|
||||
<!-- Custom Statuses -->
|
||||
<SelectGroup v-if="availableCustomStatuses.length > 0">
|
||||
<SelectLabel>Custom Statuses</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="customStatus in availableCustomStatuses"
|
||||
:key="customStatus.id"
|
||||
:value="customStatus.id"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full border"
|
||||
:style="{ backgroundColor: customStatus.color, borderColor: customStatus.color }"
|
||||
></div>
|
||||
<span class="capitalize">{{ formatStatusName(customStatus.name) }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Tasks Warning -->
|
||||
<div v-else class="p-4 border rounded-lg bg-muted/50">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
This status is not currently in use. It will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel :disabled="isDeleting">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
:disabled="isDeleting || (taskCount > 0 && !selectedReassignStatus)"
|
||||
@click="handleDelete"
|
||||
class="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
<Loader2 v-if="isDeleting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
Delete Status
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-vue-next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
customTaskStatusService,
|
||||
type CustomTaskStatus,
|
||||
type SystemTaskStatus
|
||||
} from '@/services/customTaskStatus'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
projectId: number
|
||||
status: CustomTaskStatus | null
|
||||
taskCount: number
|
||||
systemStatuses: SystemTaskStatus[]
|
||||
customStatuses: CustomTaskStatus[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
// State
|
||||
const isDeleting = ref(false)
|
||||
const selectedReassignStatus = ref<string>('')
|
||||
|
||||
// Computed
|
||||
const availableSystemStatuses = computed(() => props.systemStatuses)
|
||||
|
||||
const availableCustomStatuses = computed(() => {
|
||||
// Filter out the status being deleted
|
||||
return props.customStatuses.filter(s => s.id !== props.status?.id)
|
||||
})
|
||||
|
||||
const formatStatusName = (name: string): string => {
|
||||
return name.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
// Methods
|
||||
const handleDelete = async () => {
|
||||
if (!props.status) return
|
||||
|
||||
// If status is in use, require reassignment selection
|
||||
if (props.taskCount > 0 && !selectedReassignStatus.value) {
|
||||
toast({
|
||||
title: 'Reassignment Required',
|
||||
description: 'Please select a status to reassign tasks to',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isDeleting.value = true
|
||||
|
||||
await customTaskStatusService.deleteStatus(
|
||||
props.projectId,
|
||||
props.status.id,
|
||||
selectedReassignStatus.value || undefined
|
||||
)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Status "${props.status.name}" has been deleted${
|
||||
props.taskCount > 0 ? ` and ${props.taskCount} task${props.taskCount !== 1 ? 's' : ''} reassigned` : ''
|
||||
}`
|
||||
})
|
||||
|
||||
emit('success')
|
||||
emit('update:open', false)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete status:', error)
|
||||
|
||||
// Handle specific error cases
|
||||
const errorDetail = error.response?.data?.detail
|
||||
let errorMessage = 'Failed to delete status'
|
||||
|
||||
if (typeof errorDetail === 'object' && errorDetail.error) {
|
||||
errorMessage = errorDetail.error
|
||||
} else if (typeof errorDetail === 'string') {
|
||||
errorMessage = errorDetail
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset selected status when dialog opens/closes
|
||||
watch(() => props.open, (newValue) => {
|
||||
if (!newValue) {
|
||||
selectedReassignStatus.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleOpenChange">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isEditMode ? 'Edit Status' : 'Add Status' }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ isEditMode ? 'Update the status name and color' : 'Create a new custom task status' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Status Name Input -->
|
||||
<div class="space-y-2">
|
||||
<Label for="status_name">Status Name</Label>
|
||||
<Input
|
||||
id="status_name"
|
||||
v-model="formData.name"
|
||||
placeholder="e.g., In Review, Waiting for Approval"
|
||||
required
|
||||
maxlength="50"
|
||||
@input="validateName"
|
||||
/>
|
||||
<p v-if="nameError" class="text-sm text-destructive">{{ nameError }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ formData.name.length }}/50 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div class="space-y-2">
|
||||
<Label>Status Color</Label>
|
||||
<div class="space-y-3">
|
||||
<!-- Predefined Color Palette -->
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<button
|
||||
v-for="color in colorPalette"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="w-10 h-10 rounded-md border-2 transition-all hover:scale-110"
|
||||
:class="formData.color === color ? 'border-primary ring-2 ring-primary ring-offset-2' : 'border-border'"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="selectColor(color)"
|
||||
:title="color"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Custom Color Input -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="custom_color" class="text-sm">Custom:</Label>
|
||||
<Input
|
||||
id="custom_color"
|
||||
v-model="formData.color"
|
||||
type="text"
|
||||
placeholder="#000000"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
maxlength="7"
|
||||
class="w-32 font-mono text-sm"
|
||||
@input="validateColor"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
v-model="formData.color"
|
||||
class="w-10 h-10 rounded border cursor-pointer"
|
||||
title="Pick a color"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="colorError" class="text-sm text-destructive">{{ colorError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Preview -->
|
||||
<div class="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div class="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
|
||||
<div
|
||||
class="px-3 py-1 rounded-md text-sm font-medium text-white"
|
||||
:style="{ backgroundColor: formData.color || '#6B7280' }"
|
||||
>
|
||||
{{ formData.name || 'Status Name' }}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
This is how the status will appear
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Set as Default (Edit mode only) -->
|
||||
<div v-if="isEditMode" class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_default"
|
||||
v-model="formData.is_default"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<Label for="is_default" class="text-sm font-normal cursor-pointer">
|
||||
Set as default status for new tasks
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="submitError" class="p-3 border border-destructive rounded-lg bg-destructive/10">
|
||||
<p class="text-sm text-destructive">{{ submitError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="handleCancel">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" :disabled="isSubmitting || !isFormValid">
|
||||
<span v-if="isSubmitting" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{{ isEditMode ? 'Updating...' : 'Creating...' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ isEditMode ? 'Update Status' : 'Create Status' }}
|
||||
</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { customTaskStatusService, type CustomTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
projectId: number
|
||||
status?: CustomTaskStatus | null
|
||||
existingStatusNames?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: null,
|
||||
existingStatusNames: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'success': []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
// Predefined color palette (matching backend)
|
||||
const colorPalette = [
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#14B8A6', // Teal
|
||||
'#F97316', // Orange
|
||||
'#06B6D4', // Cyan
|
||||
'#84CC16', // Lime
|
||||
'#A855F7', // Violet
|
||||
'#F43F5E', // Rose
|
||||
'#22D3EE', // Sky
|
||||
'#FACC15', // Yellow
|
||||
]
|
||||
|
||||
// Form state
|
||||
const formData = ref({
|
||||
name: '',
|
||||
color: colorPalette[0],
|
||||
is_default: false
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const nameError = ref('')
|
||||
const colorError = ref('')
|
||||
const submitError = ref('')
|
||||
|
||||
// Computed
|
||||
const isEditMode = computed(() => !!props.status)
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
formData.value.name.trim().length > 0 &&
|
||||
formData.value.name.trim().length <= 50 &&
|
||||
/^#[0-9A-Fa-f]{6}$/.test(formData.value.color) &&
|
||||
!nameError.value &&
|
||||
!colorError.value
|
||||
)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectColor = (color: string) => {
|
||||
formData.value.color = color
|
||||
colorError.value = ''
|
||||
}
|
||||
|
||||
const validateName = () => {
|
||||
const name = formData.value.name.trim()
|
||||
nameError.value = ''
|
||||
|
||||
if (!name) {
|
||||
nameError.value = 'Status name is required'
|
||||
return
|
||||
}
|
||||
|
||||
if (name.length > 50) {
|
||||
nameError.value = 'Status name must be 50 characters or less'
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate names (case-insensitive)
|
||||
const isDuplicate = props.existingStatusNames.some(
|
||||
existingName =>
|
||||
existingName.toLowerCase() === name.toLowerCase() &&
|
||||
(!isEditMode.value || existingName.toLowerCase() !== props.status?.name.toLowerCase())
|
||||
)
|
||||
|
||||
if (isDuplicate) {
|
||||
nameError.value = 'A status with this name already exists'
|
||||
}
|
||||
}
|
||||
|
||||
const validateColor = () => {
|
||||
const color = formData.value.color
|
||||
colorError.value = ''
|
||||
|
||||
if (!color) {
|
||||
colorError.value = 'Color is required'
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||
colorError.value = 'Color must be a valid hex code (e.g., #FF5733)'
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate before submit
|
||||
validateName()
|
||||
validateColor()
|
||||
|
||||
if (!isFormValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
submitError.value = ''
|
||||
|
||||
if (isEditMode.value && props.status) {
|
||||
// Update existing status
|
||||
await customTaskStatusService.updateStatus(
|
||||
props.projectId,
|
||||
props.status.id,
|
||||
{
|
||||
name: formData.value.name.trim(),
|
||||
color: formData.value.color,
|
||||
is_default: formData.value.is_default
|
||||
}
|
||||
)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Status updated successfully'
|
||||
})
|
||||
} else {
|
||||
// Create new status
|
||||
await customTaskStatusService.createStatus(
|
||||
props.projectId,
|
||||
{
|
||||
name: formData.value.name.trim(),
|
||||
color: formData.value.color
|
||||
}
|
||||
)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Status created successfully'
|
||||
})
|
||||
}
|
||||
|
||||
emit('success')
|
||||
emit('update:open', false)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save status:', error)
|
||||
submitError.value = error.response?.data?.detail || 'Failed to save status'
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: submitError.value,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value && !isSubmitting.value) {
|
||||
emit('update:open', value)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
if (isEditMode.value && props.status) {
|
||||
// Pre-fill form with existing status data
|
||||
formData.value = {
|
||||
name: props.status.name,
|
||||
color: props.status.color,
|
||||
is_default: props.status.is_default
|
||||
}
|
||||
} else {
|
||||
// Reset to defaults for new status
|
||||
formData.value = {
|
||||
name: '',
|
||||
color: colorPalette[0],
|
||||
is_default: false
|
||||
}
|
||||
}
|
||||
|
||||
nameError.value = ''
|
||||
colorError.value = ''
|
||||
submitError.value = ''
|
||||
}
|
||||
|
||||
// Watch for dialog open/close and status changes
|
||||
watch(() => props.open, (newValue) => {
|
||||
if (newValue) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.status, () => {
|
||||
if (props.open) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Custom Task Statuses</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Define custom task statuses with colors to match your production workflow
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="loadError" class="p-4 border border-destructive rounded-lg bg-destructive/10">
|
||||
<p class="text-sm text-destructive">{{ loadError }}</p>
|
||||
<Button size="sm" variant="outline" class="mt-2" @click="loadStatuses">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- System Statuses Section -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Shield class="h-5 w-5 text-muted-foreground" />
|
||||
<h4 class="font-semibold">System Statuses</h4>
|
||||
</div>
|
||||
|
||||
<!-- System Statuses List -->
|
||||
<div class="border rounded-lg divide-y">
|
||||
<div
|
||||
v-for="systemStatus in systemStatuses"
|
||||
:key="systemStatus.id"
|
||||
class="flex items-center justify-between p-3 bg-muted/30"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-4 h-4 rounded-full border-2"
|
||||
:style="{ backgroundColor: systemStatus.color, borderColor: systemStatus.color }"
|
||||
></div>
|
||||
<span class="font-medium capitalize">{{ formatStatusName(systemStatus.name) }}</span>
|
||||
<Badge variant="secondary">
|
||||
System
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ getTaskCount(systemStatus.id) }} task{{ getTaskCount(systemStatus.id) !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Custom Statuses Section -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Palette class="h-5 w-5 text-muted-foreground" />
|
||||
<h4 class="font-semibold">Custom Statuses</h4>
|
||||
</div>
|
||||
<Button size="sm" @click="openAddDialog">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Custom Statuses List -->
|
||||
<div class="border rounded-lg divide-y">
|
||||
<template v-if="customStatuses.length > 0">
|
||||
<VueDraggableNext
|
||||
v-model="customStatuses"
|
||||
:animation="200"
|
||||
handle=".drag-handle"
|
||||
ghost-class="opacity-50"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd"
|
||||
:disabled="isReordering"
|
||||
class="divide-y"
|
||||
>
|
||||
<div
|
||||
v-for="customStatus in customStatuses"
|
||||
:key="customStatus.id"
|
||||
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
:class="{ 'cursor-move': !isReordering, 'opacity-50': isReordering }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Drag Handle -->
|
||||
<div
|
||||
class="drag-handle cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
|
||||
:class="{ 'cursor-not-allowed': isReordering }"
|
||||
>
|
||||
<GripVertical class="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-4 h-4 rounded-full border-2"
|
||||
:style="{ backgroundColor: customStatus.color, borderColor: customStatus.color }"
|
||||
></div>
|
||||
<span class="font-medium capitalize">{{ formatStatusName(customStatus.name) }}</span>
|
||||
<Badge v-if="customStatus.is_default" variant="default">
|
||||
<Star class="h-3 w-3 mr-1" />
|
||||
Default
|
||||
</Badge>
|
||||
<Badge v-else variant="outline">
|
||||
Custom
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ getTaskCount(customStatus.id) }} task{{ getTaskCount(customStatus.id) !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="!customStatus.is_default"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="handleSetAsDefault(customStatus)"
|
||||
:disabled="isReordering || isSettingDefault"
|
||||
title="Set as default status for new tasks"
|
||||
>
|
||||
<Star class="h-4 w-4 mr-1" />
|
||||
Set as Default
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openEditDialog(customStatus)"
|
||||
:disabled="isReordering"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="handleDelete(customStatus)"
|
||||
:disabled="isReordering"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VueDraggableNext>
|
||||
</template>
|
||||
<div v-else class="p-4 text-center text-sm text-muted-foreground">
|
||||
No custom statuses defined. Click "Add Status" to create one.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Status Dialog -->
|
||||
<CustomTaskStatusDialog
|
||||
:open="isDialogOpen"
|
||||
@update:open="isDialogOpen = $event"
|
||||
:project-id="props.projectId"
|
||||
:status="editingStatus"
|
||||
:existing-status-names="existingStatusNames"
|
||||
@success="handleDialogSuccess"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<CustomTaskStatusDeleteDialog
|
||||
:open="isDeleteDialogOpen"
|
||||
@update:open="isDeleteDialogOpen = $event"
|
||||
:project-id="props.projectId"
|
||||
:status="deletingStatus"
|
||||
:task-count="deletingStatusTaskCount"
|
||||
:system-statuses="systemStatuses"
|
||||
:custom-statuses="customStatuses"
|
||||
@success="handleDeleteSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Shield, Palette, Plus, Pencil, Trash2, Star, GripVertical } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
customTaskStatusService,
|
||||
type AllTaskStatusesResponse,
|
||||
type CustomTaskStatus
|
||||
} from '@/services/customTaskStatus'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import CustomTaskStatusDialog from './CustomTaskStatusDialog.vue'
|
||||
import CustomTaskStatusDeleteDialog from './CustomTaskStatusDeleteDialog.vue'
|
||||
import { VueDraggableNext } from 'vue-draggable-next'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
// Use the shared task statuses store to invalidate cache when statuses change
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
const loadError = ref('')
|
||||
const allStatuses = ref<AllTaskStatusesResponse | null>(null)
|
||||
const taskCounts = ref<Record<string, number>>({})
|
||||
|
||||
// Dialog state
|
||||
const isDialogOpen = ref(false)
|
||||
const editingStatus = ref<CustomTaskStatus | null>(null)
|
||||
|
||||
// Delete dialog state
|
||||
const isDeleteDialogOpen = ref(false)
|
||||
const deletingStatus = ref<CustomTaskStatus | null>(null)
|
||||
const deletingStatusTaskCount = ref(0)
|
||||
|
||||
// Drag and drop state
|
||||
const isDragging = ref(false)
|
||||
const isReordering = ref(false)
|
||||
|
||||
// Set as default state
|
||||
const isSettingDefault = ref(false)
|
||||
|
||||
// Computed
|
||||
const systemStatuses = computed(() => allStatuses.value?.system_statuses || [])
|
||||
const customStatuses = computed({
|
||||
get: () => allStatuses.value?.statuses || [],
|
||||
set: (value) => {
|
||||
if (allStatuses.value) {
|
||||
allStatuses.value.statuses = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const existingStatusNames = computed(() => {
|
||||
const systemNames = systemStatuses.value.map(s => s.name)
|
||||
const customNames = customStatuses.value.map(s => s.name)
|
||||
return [...systemNames, ...customNames]
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadStatuses = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
loadError.value = ''
|
||||
allStatuses.value = await customTaskStatusService.getAllStatuses(props.projectId)
|
||||
|
||||
// Load task counts for each status
|
||||
await loadTaskCounts()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load task statuses:', error)
|
||||
loadError.value = error.response?.data?.detail || 'Failed to load task statuses'
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: loadError.value,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTaskCounts = async () => {
|
||||
// For now, initialize with 0 counts
|
||||
// This will be populated when we implement the task count API
|
||||
const counts: Record<string, number> = {}
|
||||
|
||||
// Initialize system status counts
|
||||
systemStatuses.value.forEach(status => {
|
||||
counts[status.id] = 0
|
||||
})
|
||||
|
||||
// Initialize custom status counts
|
||||
customStatuses.value.forEach(status => {
|
||||
counts[status.id] = 0
|
||||
})
|
||||
|
||||
taskCounts.value = counts
|
||||
|
||||
// TODO: Implement actual task count fetching from API
|
||||
// This would require a new endpoint like:
|
||||
// GET /projects/{project_id}/task-statuses/counts
|
||||
// For now, we'll show 0 for all statuses
|
||||
}
|
||||
|
||||
const getTaskCount = (statusId: string): number => {
|
||||
return taskCounts.value[statusId] || 0
|
||||
}
|
||||
|
||||
const formatStatusName = (name: string): string => {
|
||||
return name.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
editingStatus.value = null
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (customStatus: CustomTaskStatus) => {
|
||||
editingStatus.value = customStatus
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const handleDialogSuccess = async () => {
|
||||
// Invalidate store cache and reload statuses after successful create/update
|
||||
taskStatusesStore.invalidateProject(props.projectId)
|
||||
await loadStatuses()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
const handleDelete = (customStatus: CustomTaskStatus) => {
|
||||
deletingStatus.value = customStatus
|
||||
deletingStatusTaskCount.value = getTaskCount(customStatus.id)
|
||||
isDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
const handleDeleteSuccess = async () => {
|
||||
// Invalidate store cache and reload statuses after successful deletion
|
||||
taskStatusesStore.invalidateProject(props.projectId)
|
||||
await loadStatuses()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
const handleSetAsDefault = async (customStatus: CustomTaskStatus) => {
|
||||
try {
|
||||
isSettingDefault.value = true
|
||||
|
||||
// Update the status to set is_default to true
|
||||
await customTaskStatusService.updateStatus(
|
||||
props.projectId,
|
||||
customStatus.id,
|
||||
{
|
||||
is_default: true
|
||||
}
|
||||
)
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `"${customStatus.name}" is now the default status for new tasks`
|
||||
})
|
||||
|
||||
// Invalidate store cache and reload to get the updated statuses
|
||||
taskStatusesStore.invalidateProject(props.projectId)
|
||||
await loadStatuses()
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to set default status:', error)
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to set default status',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isSettingDefault.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
const onDragStart = () => {
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
const onDragEnd = async (event: any) => {
|
||||
isDragging.value = false
|
||||
|
||||
// Check if order actually changed
|
||||
if (event.oldIndex === event.newIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the new order of status IDs
|
||||
const newOrder = customStatuses.value.map(status => status.id)
|
||||
|
||||
try {
|
||||
isReordering.value = true
|
||||
|
||||
// Call the reorder API
|
||||
await customTaskStatusService.reorderStatuses(props.projectId, {
|
||||
status_ids: newOrder
|
||||
})
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Status order updated successfully'
|
||||
})
|
||||
|
||||
// Invalidate store cache and reload to get the updated order from server
|
||||
taskStatusesStore.invalidateProject(props.projectId)
|
||||
await loadStatuses()
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
console.error('Failed to reorder statuses:', error)
|
||||
|
||||
// Reload to revert to server state on error
|
||||
await loadStatuses()
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to reorder statuses',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isReordering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadStatuses()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Custom Task Types</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Add custom task types beyond the standard types to adapt the pipeline to your project needs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Asset Task Types Section -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Package class="h-5 w-5 text-muted-foreground" />
|
||||
<h4 class="font-semibold">Asset Task Types</h4>
|
||||
</div>
|
||||
<Button size="sm" @click="openAddDialog('asset')">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Task Type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Asset Task Types List -->
|
||||
<div class="border rounded-lg divide-y">
|
||||
<template v-if="assetTaskTypes.length > 0">
|
||||
<div
|
||||
v-for="taskType in assetTaskTypes"
|
||||
:key="taskType"
|
||||
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
|
||||
<Badge v-if="isStandardAssetType(taskType)" variant="secondary">
|
||||
Standard
|
||||
</Badge>
|
||||
<Badge v-else variant="outline">
|
||||
Custom
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-if="!isStandardAssetType(taskType)" class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openEditDialog('asset', taskType)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="handleDelete('asset', taskType)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="p-4 text-center text-sm text-muted-foreground">
|
||||
No asset task types defined
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Shot Task Types Section -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Camera class="h-5 w-5 text-muted-foreground" />
|
||||
<h4 class="font-semibold">Shot Task Types</h4>
|
||||
</div>
|
||||
<Button size="sm" @click="openAddDialog('shot')">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Task Type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Shot Task Types List -->
|
||||
<div class="border rounded-lg divide-y">
|
||||
<template v-if="shotTaskTypes.length > 0">
|
||||
<div
|
||||
v-for="taskType in shotTaskTypes"
|
||||
:key="taskType"
|
||||
class="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium capitalize">{{ formatTaskTypeName(taskType) }}</span>
|
||||
<Badge v-if="isStandardShotType(taskType)" variant="secondary">
|
||||
Standard
|
||||
</Badge>
|
||||
<Badge v-else variant="outline">
|
||||
Custom
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-if="!isStandardShotType(taskType)" class="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openEditDialog('shot', taskType)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="handleDelete('shot', taskType)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="p-4 text-center text-sm text-muted-foreground">
|
||||
No shot task types defined
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<Dialog :open="isDialogOpen" @update:open="closeDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ dialogMode === 'add' ? 'Add' : 'Edit' }} {{ dialogCategory === 'asset' ? 'Asset' : 'Shot' }} Task Type
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ dialogMode === 'add'
|
||||
? 'Enter a name for the new task type. Use lowercase letters, numbers, and underscores only.'
|
||||
: 'Update the task type name. This will update all existing tasks using this type.'
|
||||
}}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="taskTypeName">Task Type Name</Label>
|
||||
<Input
|
||||
id="taskTypeName"
|
||||
v-model="taskTypeName"
|
||||
placeholder="e.g., grooming, lookdev, previz"
|
||||
:class="{ 'border-destructive': validationError }"
|
||||
@input="validateTaskTypeName"
|
||||
/>
|
||||
<p v-if="validationError" class="text-sm text-destructive">
|
||||
{{ validationError }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
3-50 characters, lowercase alphanumeric with underscores only
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeDialog">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="handleDialogSave" :disabled="!isTaskTypeNameValid || isSaving">
|
||||
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{{ dialogMode === 'add' ? 'Add' : 'Update' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog
|
||||
:open="isDeleteDialogOpen"
|
||||
@update:open="(open) => {
|
||||
isDeleteDialogOpen = open
|
||||
if (!open && !isDeleting.value) {
|
||||
// Only clear values when dialog closes and we're not in the middle of deleting
|
||||
taskTypeToDelete = ''
|
||||
categoryToDelete = ''
|
||||
deleteError = ''
|
||||
}
|
||||
}"
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Task Type</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the task type "{{ taskTypeToDelete }}"?
|
||||
<span v-if="deleteError" class="block mt-2 text-destructive font-medium">
|
||||
{{ deleteError }}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
@click="confirmDelete"
|
||||
:disabled="isDeleting"
|
||||
class="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
<div v-if="isDeleting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogFooter> </AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Package, Camera, Plus, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
const taskTypes = ref<AllTaskTypesResponse | null>(null)
|
||||
|
||||
// Dialog state
|
||||
const isDialogOpen = ref(false)
|
||||
const dialogMode = ref<'add' | 'edit'>('add')
|
||||
const dialogCategory = ref<'asset' | 'shot'>('asset')
|
||||
const taskTypeName = ref('')
|
||||
const originalTaskTypeName = ref('')
|
||||
const validationError = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// Delete dialog state
|
||||
const isDeleteDialogOpen = ref(false)
|
||||
const taskTypeToDelete = ref('')
|
||||
const categoryToDelete = ref<'asset' | 'shot'>('asset')
|
||||
const deleteError = ref('')
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// Computed
|
||||
const assetTaskTypes = computed(() => taskTypes.value?.asset_task_types || [])
|
||||
const shotTaskTypes = computed(() => taskTypes.value?.shot_task_types || [])
|
||||
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || [])
|
||||
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || [])
|
||||
|
||||
const isTaskTypeNameValid = computed(() => {
|
||||
return taskTypeName.value.length >= 3 && !validationError.value
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadTaskTypes = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load task types:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to load task types',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isStandardAssetType = (taskType: string): boolean => {
|
||||
return standardAssetTypes.value.includes(taskType)
|
||||
}
|
||||
|
||||
const isStandardShotType = (taskType: string): boolean => {
|
||||
return standardShotTypes.value.includes(taskType)
|
||||
}
|
||||
|
||||
const formatTaskTypeName = (taskType: string): string => {
|
||||
return taskType.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
const validateTaskTypeName = () => {
|
||||
const name = taskTypeName.value.trim()
|
||||
|
||||
if (name.length === 0) {
|
||||
validationError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (name.length < 3) {
|
||||
validationError.value = 'Task type name must be at least 3 characters'
|
||||
return
|
||||
}
|
||||
|
||||
if (name.length > 50) {
|
||||
validationError.value = 'Task type name must be at most 50 characters'
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||
validationError.value = 'Task type name must be lowercase alphanumeric with underscores only'
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicates (only if adding or changing name)
|
||||
if (dialogMode.value === 'add' || name !== originalTaskTypeName.value) {
|
||||
const existingTypes = dialogCategory.value === 'asset' ? assetTaskTypes.value : shotTaskTypes.value
|
||||
if (existingTypes.includes(name)) {
|
||||
validationError.value = 'A task type with this name already exists'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
validationError.value = ''
|
||||
}
|
||||
|
||||
const openAddDialog = (category: 'asset' | 'shot') => {
|
||||
dialogMode.value = 'add'
|
||||
dialogCategory.value = category
|
||||
taskTypeName.value = ''
|
||||
originalTaskTypeName.value = ''
|
||||
validationError.value = ''
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const openEditDialog = (category: 'asset' | 'shot', taskType: string) => {
|
||||
dialogMode.value = 'edit'
|
||||
dialogCategory.value = category
|
||||
taskTypeName.value = taskType
|
||||
originalTaskTypeName.value = taskType
|
||||
validationError.value = ''
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
isDialogOpen.value = false
|
||||
taskTypeName.value = ''
|
||||
originalTaskTypeName.value = ''
|
||||
validationError.value = ''
|
||||
}
|
||||
|
||||
const handleDialogSave = async () => {
|
||||
validateTaskTypeName()
|
||||
|
||||
if (!isTaskTypeNameValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving.value = true
|
||||
|
||||
if (dialogMode.value === 'add') {
|
||||
const response = await customTaskTypeService.addCustomTaskType(props.projectId, {
|
||||
task_type: taskTypeName.value.trim(),
|
||||
category: dialogCategory.value
|
||||
})
|
||||
console.log('Add task type response:', response)
|
||||
taskTypes.value = response
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Task type "${taskTypeName.value}" added successfully`
|
||||
})
|
||||
} else {
|
||||
const response = await customTaskTypeService.updateCustomTaskType(
|
||||
props.projectId,
|
||||
originalTaskTypeName.value,
|
||||
{
|
||||
old_name: originalTaskTypeName.value,
|
||||
new_name: taskTypeName.value.trim(),
|
||||
category: dialogCategory.value
|
||||
}
|
||||
)
|
||||
console.log('Update task type response:', response)
|
||||
taskTypes.value = response
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Task type updated successfully`
|
||||
})
|
||||
}
|
||||
|
||||
emit('updated')
|
||||
closeDialog()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save task type:', error)
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to save task type'
|
||||
validationError.value = errorMessage
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (category: 'asset' | 'shot', taskType: string) => {
|
||||
console.log('=== HANDLE DELETE ===')
|
||||
console.log('Received taskType:', taskType)
|
||||
console.log('Received category:', category)
|
||||
|
||||
taskTypeToDelete.value = taskType
|
||||
categoryToDelete.value = category
|
||||
deleteError.value = ''
|
||||
isDeleteDialogOpen.value = true
|
||||
|
||||
console.log('Set taskTypeToDelete.value to:', taskTypeToDelete.value)
|
||||
console.log('Set categoryToDelete.value to:', categoryToDelete.value)
|
||||
}
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
console.log('=== CLOSE DELETE DIALOG ===')
|
||||
isDeleteDialogOpen.value = false
|
||||
// Values will be cleared by @update:open handler
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
// Capture values immediately before any async operations
|
||||
const taskTypeToDeleteLocal = taskTypeToDelete.value
|
||||
const categoryToDeleteLocal = categoryToDelete.value
|
||||
|
||||
try {
|
||||
isDeleting.value = true
|
||||
deleteError.value = ''
|
||||
|
||||
console.log('=== DELETE DEBUG ===')
|
||||
console.log('taskTypeToDelete.value:', taskTypeToDeleteLocal)
|
||||
console.log('categoryToDelete.value:', categoryToDeleteLocal)
|
||||
console.log('projectId:', props.projectId)
|
||||
|
||||
if (!taskTypeToDeleteLocal) {
|
||||
console.error('ERROR: taskTypeToDelete is empty!')
|
||||
deleteError.value = 'Task type name is missing. Please try again.'
|
||||
isDeleting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Deleting task type:', {
|
||||
projectId: props.projectId,
|
||||
taskType: taskTypeToDeleteLocal,
|
||||
category: categoryToDeleteLocal
|
||||
})
|
||||
|
||||
const response = await customTaskTypeService.deleteCustomTaskType(
|
||||
props.projectId,
|
||||
taskTypeToDeleteLocal,
|
||||
categoryToDeleteLocal
|
||||
)
|
||||
console.log('Delete task type response:', response)
|
||||
taskTypes.value = response
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Task type "${taskTypeToDeleteLocal}" deleted successfully`
|
||||
})
|
||||
|
||||
emit('updated')
|
||||
|
||||
// Close dialog and clear values
|
||||
isDeleteDialogOpen.value = false
|
||||
taskTypeToDelete.value = ''
|
||||
categoryToDelete.value = ''
|
||||
deleteError.value = ''
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete task type:', error)
|
||||
const errorData = error.response?.data
|
||||
|
||||
if (errorData?.task_count) {
|
||||
deleteError.value = `Cannot delete: ${errorData.task_count} task(s) are using this type`
|
||||
} else {
|
||||
deleteError.value = errorData?.detail || 'Failed to delete task type'
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: deleteError.value,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
openEditDialog,
|
||||
handleDelete
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadTaskTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Default Task Templates</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Configure which tasks are automatically created for new assets and shots
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Asset Task Templates -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Package class="h-5 w-5 text-muted-foreground" />
|
||||
<h4 class="font-semibold">Asset Task Templates</h4>
|
||||
</div>
|
||||
|
||||
<!-- Asset Templates Table -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-32">Task Type</TableHead>
|
||||
<TableHead class="text-center">Characters</TableHead>
|
||||
<TableHead class="text-center">Props</TableHead>
|
||||
<TableHead class="text-center">Sets</TableHead>
|
||||
<TableHead class="text-center">Vehicles</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<!-- Dynamic Task Type Rows -->
|
||||
<TableRow v-for="taskType in allAssetTaskTypes" :key="taskType">
|
||||
<TableCell class="font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="capitalize">{{ taskType.replace('_', ' ') }}</span>
|
||||
<!-- Edit/Delete icons for custom task types only -->
|
||||
<div v-if="isCustomAssetType(taskType)" class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
@click="handleEditCustomTaskType(taskType, 'asset')"
|
||||
title="Edit custom task type"
|
||||
>
|
||||
<Pencil class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
@click="handleDeleteCustomTaskType(taskType, 'asset')"
|
||||
title="Delete custom task type"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
:checked="assetTemplates.characters.includes(taskType)"
|
||||
@update:checked="toggleAssetTask('characters', taskType, $event)"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
:checked="assetTemplates.props.includes(taskType)"
|
||||
@update:checked="toggleAssetTask('props', taskType, $event)"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
:checked="assetTemplates.sets.includes(taskType)"
|
||||
@update:checked="toggleAssetTask('sets', taskType, $event)"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
:checked="assetTemplates.vehicles.includes(taskType)"
|
||||
@update:checked="toggleAssetTask('vehicles', taskType, $event)"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Shot Task Templates -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Camera class="h-5 w-5 text-muted-foreground" />
|
||||
<h4 class="font-semibold">Shot Task Templates</h4>
|
||||
</div>
|
||||
|
||||
<!-- Shot Templates Table -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-48">Department / Task</TableHead>
|
||||
<TableHead class="text-center">Enabled for All Shots</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<!-- Dynamic Shot Task Type Rows -->
|
||||
<TableRow v-for="taskType in allShotTaskTypes" :key="taskType">
|
||||
<TableCell class="font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="capitalize">{{ taskType.replace('_', ' ') }}</span>
|
||||
<!-- Edit/Delete icons for custom task types only -->
|
||||
<div v-if="isCustomShotType(taskType)" class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
@click="handleEditCustomTaskType(taskType, 'shot')"
|
||||
title="Edit custom task type"
|
||||
>
|
||||
<Pencil class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
@click="handleDeleteCustomTaskType(taskType, 'shot')"
|
||||
title="Delete custom task type"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
:checked="shotTemplates.includes(taskType)"
|
||||
@update:checked="toggleShotTask(taskType, $event)"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Info class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Template Preview</span>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
When creating a new <strong>Character</strong> asset,
|
||||
{{ assetTemplates.characters.length }} task(s) will be created:
|
||||
{{ assetTemplates.characters.join(', ') || 'None' }}
|
||||
</p>
|
||||
<p>
|
||||
When creating a new <strong>Shot</strong>,
|
||||
{{ shotTemplates.length }} task(s) will be created:
|
||||
{{ shotTemplates.join(', ') || 'None' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<Button variant="outline" @click="resetToDefaults">
|
||||
<RotateCcw class="h-4 w-4 mr-2" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="handleSave" :disabled="isSaving">
|
||||
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Save Templates
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { Package, Camera, Info, RotateCcw, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { customTaskTypeService, type AllTaskTypesResponse } from '@/services/customTaskType'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface AssetTemplates {
|
||||
characters: string[]
|
||||
props: string[]
|
||||
sets: string[]
|
||||
vehicles: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
initialAssetTemplates?: AssetTemplates
|
||||
initialShotTemplates?: string[]
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
initialAssetTemplates: () => ({
|
||||
characters: ['modeling', 'surfacing', 'rigging'],
|
||||
props: ['modeling', 'surfacing'],
|
||||
sets: ['modeling', 'surfacing'],
|
||||
vehicles: ['modeling', 'surfacing', 'rigging']
|
||||
}),
|
||||
initialShotTemplates: () => ['layout', 'animation', 'simulation', 'lighting', 'compositing'],
|
||||
isSaving: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [data: { assetTemplates: AssetTemplates; shotTemplates: string[] }]
|
||||
cancel: []
|
||||
editCustomTaskType: [taskType: string, category: 'asset' | 'shot']
|
||||
deleteCustomTaskType: [taskType: string, category: 'asset' | 'shot']
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
// Default templates
|
||||
const DEFAULT_ASSET_TEMPLATES: AssetTemplates = {
|
||||
characters: ['modeling', 'surfacing', 'rigging'],
|
||||
props: ['modeling', 'surfacing'],
|
||||
sets: ['modeling', 'surfacing'],
|
||||
vehicles: ['modeling', 'surfacing', 'rigging']
|
||||
}
|
||||
|
||||
const DEFAULT_SHOT_TEMPLATES = ['layout', 'animation', 'simulation', 'lighting', 'compositing']
|
||||
|
||||
// State
|
||||
const assetTemplates = ref<AssetTemplates>({ ...props.initialAssetTemplates })
|
||||
const shotTemplates = ref<string[]>([...props.initialShotTemplates])
|
||||
const taskTypes = ref<AllTaskTypesResponse | null>(null)
|
||||
const isLoadingTaskTypes = ref(false)
|
||||
|
||||
// Computed properties for task types
|
||||
const allAssetTaskTypes = computed(() => taskTypes.value?.asset_task_types || ['modeling', 'surfacing', 'rigging'])
|
||||
const allShotTaskTypes = computed(() => taskTypes.value?.shot_task_types || ['layout', 'animation', 'simulation', 'lighting', 'compositing'])
|
||||
const standardAssetTypes = computed(() => taskTypes.value?.standard_asset_types || ['modeling', 'surfacing', 'rigging'])
|
||||
const standardShotTypes = computed(() => taskTypes.value?.standard_shot_types || ['layout', 'animation', 'simulation', 'lighting', 'compositing'])
|
||||
const customAssetTypes = computed(() => taskTypes.value?.custom_asset_types || [])
|
||||
const customShotTypes = computed(() => taskTypes.value?.custom_shot_types || [])
|
||||
|
||||
// Methods
|
||||
const loadTaskTypes = async () => {
|
||||
isLoadingTaskTypes.value = true
|
||||
try {
|
||||
taskTypes.value = await customTaskTypeService.getAllTaskTypes(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load task types:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load task types',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoadingTaskTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAssetTask = (category: keyof AssetTemplates, task: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!assetTemplates.value[category].includes(task)) {
|
||||
assetTemplates.value[category].push(task)
|
||||
}
|
||||
} else {
|
||||
assetTemplates.value[category] = assetTemplates.value[category].filter(t => t !== task)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleShotTask = (task: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!shotTemplates.value.includes(task)) {
|
||||
shotTemplates.value.push(task)
|
||||
}
|
||||
} else {
|
||||
shotTemplates.value = shotTemplates.value.filter(t => t !== task)
|
||||
}
|
||||
}
|
||||
|
||||
const resetToDefaults = () => {
|
||||
assetTemplates.value = { ...DEFAULT_ASSET_TEMPLATES }
|
||||
shotTemplates.value = [...DEFAULT_SHOT_TEMPLATES]
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
emit('save', {
|
||||
assetTemplates: assetTemplates.value,
|
||||
shotTemplates: shotTemplates.value
|
||||
})
|
||||
}
|
||||
|
||||
const isCustomAssetType = (taskType: string) => {
|
||||
return customAssetTypes.value.includes(taskType)
|
||||
}
|
||||
|
||||
const isCustomShotType = (taskType: string) => {
|
||||
return customShotTypes.value.includes(taskType)
|
||||
}
|
||||
|
||||
const handleEditCustomTaskType = (taskType: string, category: 'asset' | 'shot') => {
|
||||
emit('editCustomTaskType', taskType, category)
|
||||
}
|
||||
|
||||
const handleDeleteCustomTaskType = (taskType: string, category: 'asset' | 'shot') => {
|
||||
emit('deleteCustomTaskType', taskType, category)
|
||||
}
|
||||
|
||||
// Expose method to refresh task types
|
||||
const refreshTaskTypes = async () => {
|
||||
await loadTaskTypes()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshTaskTypes
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadTaskTypes()
|
||||
})
|
||||
|
||||
// Watch for prop changes
|
||||
watch(() => props.initialAssetTemplates, (newVal) => {
|
||||
if (newVal) {
|
||||
assetTemplates.value = { ...newVal }
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.initialShotTemplates, (newVal) => {
|
||||
if (newVal) {
|
||||
shotTemplates.value = [...newVal]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Episode Management</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Create and manage episodes for organizing shots in this project
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="showCreateDialog = true" v-if="canManage">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
New Episode
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 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 episodes...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load episodes</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="loadEpisodes" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Episodes Table -->
|
||||
<div v-else-if="episodes.length > 0" class="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20">#</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead class="w-24 text-center">Shots</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead class="w-32 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="episode in sortedEpisodes" :key="episode.id">
|
||||
<TableCell>
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{{ episode.episode_number }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="font-medium">{{ episode.name }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getStatusVariant(episode.status)">
|
||||
{{ formatStatus(episode.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<span class="font-medium">{{ episode.shot_count || 0 }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground text-sm">
|
||||
{{ episode.description || '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="editEpisode(episode)"
|
||||
v-if="canManage"
|
||||
>
|
||||
<Edit class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="confirmDelete(episode)"
|
||||
v-if="canManage"
|
||||
:disabled="(episode.shot_count || 0) > 0"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" :class="{ 'text-destructive': (episode.shot_count || 0) === 0 }" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12 border rounded-lg">
|
||||
<Film class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No episodes yet</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
Create your first episode to start organizing shots
|
||||
</p>
|
||||
<Button @click="showCreateDialog = true" v-if="canManage">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create Episode
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<Dialog :open="showCreateDialog || showEditDialog" @update:open="closeDialogs">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ editingEpisode ? 'Edit Episode' : 'Create New Episode' }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ editingEpisode
|
||||
? 'Update episode information and status'
|
||||
: 'Add a new episode to organize shots in this project'
|
||||
}}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<Label for="episode_number">Episode Number</Label>
|
||||
<Input
|
||||
id="episode_number"
|
||||
v-model.number="form.episode_number"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Enter episode number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="name">Episode Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="Enter episode name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="Brief description of the episode"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="status">Status</Label>
|
||||
<Select :model-value="form.status" @update:model-value="form.status = $event">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="planning">Planning</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="on_hold">On Hold</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="closeDialogs">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" :disabled="isSubmitting">
|
||||
<div v-if="isSubmitting" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{{ editingEpisode ? 'Update Episode' : 'Create Episode' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Episode</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete episode "{{ deletingEpisode?.name }}"?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDelete" class="bg-destructive hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Plus, Edit, Trash2, AlertCircle, RefreshCw, Film } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useEpisodesStore, type Episode, type EpisodeCreate, type EpisodeUpdate } from '@/stores/episodes'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
canManage?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
canManage: false
|
||||
})
|
||||
|
||||
const episodesStore = useEpisodesStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const episodes = ref<Episode[]>([])
|
||||
const showCreateDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingEpisode = ref<Episode | null>(null)
|
||||
const deletingEpisode = ref<Episode | null>(null)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
episode_number: 1,
|
||||
description: '',
|
||||
status: 'planning' as const
|
||||
})
|
||||
|
||||
// Computed
|
||||
const sortedEpisodes = computed(() => {
|
||||
return [...episodes.value].sort((a, b) => a.episode_number - b.episode_number)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadEpisodes = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
await episodesStore.fetchEpisodes(props.projectId)
|
||||
// fetchEpisodes with projectId already filters by project, so we can use episodes directly
|
||||
episodes.value = episodesStore.episodes
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load episodes'
|
||||
console.error('Failed to load episodes:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editEpisode = (episode: Episode) => {
|
||||
editingEpisode.value = episode
|
||||
form.value = {
|
||||
name: episode.name,
|
||||
episode_number: episode.episode_number,
|
||||
description: episode.description || '',
|
||||
status: episode.status
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = (episode: Episode) => {
|
||||
if ((episode.shot_count || 0) > 0) {
|
||||
toast({
|
||||
title: 'Cannot delete episode',
|
||||
description: 'This episode contains shots. Please delete all shots first.',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
deletingEpisode.value = episode
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
episode_number: form.value.episode_number,
|
||||
description: form.value.description || undefined,
|
||||
status: form.value.status
|
||||
}
|
||||
|
||||
if (editingEpisode.value) {
|
||||
// Update existing episode
|
||||
await episodesStore.updateEpisode(editingEpisode.value.id, data)
|
||||
toast({
|
||||
title: 'Episode updated',
|
||||
description: `Episode "${data.name}" has been updated successfully.`
|
||||
})
|
||||
} else {
|
||||
// Create new episode
|
||||
await episodesStore.createEpisode(props.projectId, data)
|
||||
toast({
|
||||
title: 'Episode created',
|
||||
description: `Episode "${data.name}" has been created successfully.`
|
||||
})
|
||||
}
|
||||
|
||||
// Reload episodes
|
||||
await loadEpisodes()
|
||||
closeDialogs()
|
||||
} catch (err) {
|
||||
console.error('Failed to save episode:', err)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to save episode',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingEpisode.value) return
|
||||
|
||||
try {
|
||||
await episodesStore.deleteEpisode(deletingEpisode.value.id)
|
||||
toast({
|
||||
title: 'Episode deleted',
|
||||
description: `Episode "${deletingEpisode.value.name}" has been deleted successfully.`
|
||||
})
|
||||
await loadEpisodes()
|
||||
showDeleteDialog.value = false
|
||||
deletingEpisode.value = null
|
||||
} catch (err) {
|
||||
console.error('Failed to delete episode:', err)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to delete episode',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const closeDialogs = () => {
|
||||
showCreateDialog.value = false
|
||||
showEditDialog.value = false
|
||||
editingEpisode.value = null
|
||||
form.value = {
|
||||
name: '',
|
||||
episode_number: 1,
|
||||
description: '',
|
||||
status: 'planning'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'planning': return 'secondary'
|
||||
case 'in_progress': return 'default'
|
||||
case 'on_hold': return 'outline'
|
||||
case 'completed': return 'success'
|
||||
case 'cancelled': return 'destructive'
|
||||
default: return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadEpisodes()
|
||||
})
|
||||
|
||||
// Watch for project changes
|
||||
watch(() => props.projectId, () => {
|
||||
loadEpisodes()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Upload Limit Section -->
|
||||
<GlobalUploadLimitEditor />
|
||||
|
||||
<!-- All Settings Management -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Settings class="h-5 w-5" />
|
||||
All Global Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage all system-wide configuration settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<!-- Add New Setting -->
|
||||
<div class="border rounded-lg p-4 space-y-3">
|
||||
<h4 class="font-medium">Add New Setting</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label for="new-key">Setting Key</Label>
|
||||
<Input
|
||||
id="new-key"
|
||||
v-model="newSetting.setting_key"
|
||||
placeholder="e.g., max_concurrent_uploads"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="new-value">Value</Label>
|
||||
<Input
|
||||
id="new-value"
|
||||
v-model="newSetting.setting_value"
|
||||
placeholder="Setting value"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="new-description">Description</Label>
|
||||
<Input
|
||||
id="new-description"
|
||||
v-model="newSetting.description"
|
||||
placeholder="Optional description"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@click="createNewSetting"
|
||||
:disabled="loading || !canCreateSetting"
|
||||
size="sm"
|
||||
>
|
||||
<Loader2 v-if="loading" class="h-4 w-4 animate-spin mr-2" />
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Add Setting
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Settings List -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-medium">Current Settings</h4>
|
||||
<Button
|
||||
@click="refreshSettings"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && allSettings.length === 0" class="text-center py-8">
|
||||
<Loader2 class="h-6 w-6 animate-spin mx-auto mb-2" />
|
||||
<p class="text-sm text-muted-foreground">Loading settings...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="allSettings.length === 0" class="text-center py-8">
|
||||
<Settings class="h-12 w-12 mx-auto mb-2 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">No settings found</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="setting in allSettings"
|
||||
:key="setting.id"
|
||||
class="border rounded-lg p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-sm font-mono bg-muted px-2 py-1 rounded">
|
||||
{{ setting.setting_key }}
|
||||
</code>
|
||||
<Badge v-if="setting.setting_key === 'global_upload_limit_mb'" variant="secondary">
|
||||
System
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm">{{ setting.setting_value }}</p>
|
||||
<p v-if="setting.description" class="text-xs text-muted-foreground">
|
||||
{{ setting.description }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Updated: {{ formatDate(setting.updated_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
@click="editSetting(setting)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
>
|
||||
<Edit class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="deleteSetting(setting.setting_key)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading || setting.setting_key === 'global_upload_limit_mb'"
|
||||
class="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-4 text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Edit Setting Dialog -->
|
||||
<Dialog v-model:open="editDialogOpen">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Setting</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the value and description for this setting
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label>Setting Key</Label>
|
||||
<Input :value="editingSetting?.setting_key" disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="edit-value">Value</Label>
|
||||
<Input
|
||||
id="edit-value"
|
||||
v-model="editForm.setting_value"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="edit-description">Description</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
v-model="editForm.description"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="editDialogOpen = false" :disabled="loading">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="updateSetting" :disabled="loading">
|
||||
<Loader2 v-if="loading" class="h-4 w-4 animate-spin mr-2" />
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Settings, Plus, Edit, Trash2, RefreshCw, Loader2 } from 'lucide-vue-next'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import type { GlobalSetting } from '@/services/settings'
|
||||
import GlobalUploadLimitEditor from './GlobalUploadLimitEditor.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
const newSetting = ref({
|
||||
setting_key: '',
|
||||
setting_value: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const editDialogOpen = ref(false)
|
||||
const editingSetting = ref<GlobalSetting | null>(null)
|
||||
const editForm = ref({
|
||||
setting_value: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const loading = computed(() => settingsStore.loading)
|
||||
const allSettings = computed(() => settingsStore.allSettings)
|
||||
const error = computed(() => settingsStore.error)
|
||||
|
||||
const canCreateSetting = computed(() =>
|
||||
newSetting.value.setting_key.trim() && newSetting.value.setting_value.trim()
|
||||
)
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
async function createNewSetting() {
|
||||
if (!canCreateSetting.value) return
|
||||
|
||||
try {
|
||||
await settingsStore.createSetting(newSetting.value)
|
||||
|
||||
toast({
|
||||
title: 'Setting created',
|
||||
description: `Setting "${newSetting.value.setting_key}" has been created`
|
||||
})
|
||||
|
||||
// Reset form
|
||||
newSetting.value = {
|
||||
setting_key: '',
|
||||
setting_value: '',
|
||||
description: ''
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to create setting',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function editSetting(setting: GlobalSetting) {
|
||||
editingSetting.value = setting
|
||||
editForm.value = {
|
||||
setting_value: setting.setting_value,
|
||||
description: setting.description || ''
|
||||
}
|
||||
editDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function updateSetting() {
|
||||
if (!editingSetting.value) return
|
||||
|
||||
try {
|
||||
await settingsStore.updateSetting(editingSetting.value.setting_key, editForm.value)
|
||||
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
description: `Setting "${editingSetting.value.setting_key}" has been updated`
|
||||
})
|
||||
|
||||
editDialogOpen.value = false
|
||||
editingSetting.value = null
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update setting',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSetting(settingKey: string) {
|
||||
if (settingKey === 'global_upload_limit_mb') {
|
||||
toast({
|
||||
title: 'Cannot delete',
|
||||
description: 'The global upload limit setting cannot be deleted',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the setting "${settingKey}"?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await settingsStore.deleteSetting(settingKey)
|
||||
|
||||
toast({
|
||||
title: 'Setting deleted',
|
||||
description: `Setting "${settingKey}" has been deleted`
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to delete setting',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSettings() {
|
||||
await settingsStore.fetchAllSettings()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.fetchAllSettings()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Upload class="h-5 w-5" />
|
||||
Global Upload Limit
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Set the maximum file size limit for movie uploads across all projects
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="upload-limit">Upload Limit (MB)</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
id="upload-limit"
|
||||
v-model.number="uploadLimitMB"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="10000"
|
||||
class="w-32"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">MB</span>
|
||||
<Button
|
||||
@click="updateLimit"
|
||||
:disabled="loading || !hasChanged"
|
||||
size="sm"
|
||||
>
|
||||
<Loader2 v-if="loading" class="h-4 w-4 animate-spin mr-2" />
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Current limit: {{ formatFileSize(currentLimitBytes) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Quick Presets</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="uploadLimitMB = preset.value"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-muted p-3 text-sm">
|
||||
<p class="font-medium mb-1">Note:</p>
|
||||
<p class="text-muted-foreground">
|
||||
This limit applies to all movie file uploads (mov, mp4, etc.) across all projects.
|
||||
Artists will see this limit during file submission and uploads exceeding this size will be rejected.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Upload, Loader2 } from 'lucide-vue-next'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
const uploadLimitMB = ref(1000)
|
||||
const loading = ref(false)
|
||||
|
||||
const presets = [
|
||||
{ label: '500 MB', value: 500 },
|
||||
{ label: '1 GB', value: 1000 },
|
||||
{ label: '2 GB', value: 2000 },
|
||||
{ label: '5 GB', value: 5000 },
|
||||
{ label: '10 GB', value: 10000 }
|
||||
]
|
||||
|
||||
const currentLimitBytes = computed(() => settingsStore.uploadLimitBytes)
|
||||
const hasChanged = computed(() => uploadLimitMB.value !== settingsStore.uploadLimitMB)
|
||||
const error = computed(() => settingsStore.error)
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
async function updateLimit() {
|
||||
if (!hasChanged.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
await settingsStore.updateUploadLimit(uploadLimitMB.value)
|
||||
|
||||
toast({
|
||||
title: 'Upload limit updated',
|
||||
description: `Global upload limit set to ${formatFileSize(uploadLimitMB.value * 1024 * 1024)}`
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update upload limit',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.fetchUploadLimit()
|
||||
uploadLimitMB.value = settingsStore.uploadLimitMB
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Configure how you want to receive notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<p class="text-muted-foreground">Loading preferences...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="preferences" class="space-y-6">
|
||||
<!-- Email Notifications -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Email Notifications</h3>
|
||||
<p class="text-sm text-muted-foreground">Receive notifications via email</p>
|
||||
</div>
|
||||
<Switch
|
||||
:checked="preferences.email_enabled"
|
||||
@update:checked="(val) => updatePreference('email_enabled', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="preferences.email_enabled" class="ml-6 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Task assigned to me</Label>
|
||||
<Switch
|
||||
:checked="preferences.email_task_assigned"
|
||||
@update:checked="(val) => updatePreference('email_task_assigned', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Task status changes</Label>
|
||||
<Switch
|
||||
:checked="preferences.email_task_status_changed"
|
||||
@update:checked="(val) => updatePreference('email_task_status_changed', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Submission reviewed</Label>
|
||||
<Switch
|
||||
:checked="preferences.email_submission_reviewed"
|
||||
@update:checked="(val) => updatePreference('email_submission_reviewed', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Work submitted for review</Label>
|
||||
<Switch
|
||||
:checked="preferences.email_work_submitted"
|
||||
@update:checked="(val) => updatePreference('email_work_submitted', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Deadline approaching</Label>
|
||||
<Switch
|
||||
:checked="preferences.email_deadline_approaching"
|
||||
@update:checked="(val) => updatePreference('email_deadline_approaching', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Project updates</Label>
|
||||
<Switch
|
||||
:checked="preferences.email_project_update"
|
||||
@update:checked="(val) => updatePreference('email_project_update', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Comments added</Label>
|
||||
<Switch
|
||||
:checked="preferences.email_comment_added"
|
||||
@update:checked="(val) => updatePreference('email_comment_added', val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- In-App Notifications -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">In-App Notifications</h3>
|
||||
<p class="text-sm text-muted-foreground">Show notifications in the application</p>
|
||||
</div>
|
||||
<Switch
|
||||
:checked="preferences.inapp_enabled"
|
||||
@update:checked="(val) => updatePreference('inapp_enabled', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="preferences.inapp_enabled" class="ml-6 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Task assigned to me</Label>
|
||||
<Switch
|
||||
:checked="preferences.inapp_task_assigned"
|
||||
@update:checked="(val) => updatePreference('inapp_task_assigned', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Task status changes</Label>
|
||||
<Switch
|
||||
:checked="preferences.inapp_task_status_changed"
|
||||
@update:checked="(val) => updatePreference('inapp_task_status_changed', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Submission reviewed</Label>
|
||||
<Switch
|
||||
:checked="preferences.inapp_submission_reviewed"
|
||||
@update:checked="(val) => updatePreference('inapp_submission_reviewed', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Work submitted for review</Label>
|
||||
<Switch
|
||||
:checked="preferences.inapp_work_submitted"
|
||||
@update:checked="(val) => updatePreference('inapp_work_submitted', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Deadline approaching</Label>
|
||||
<Switch
|
||||
:checked="preferences.inapp_deadline_approaching"
|
||||
@update:checked="(val) => updatePreference('inapp_deadline_approaching', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Project updates</Label>
|
||||
<Switch
|
||||
:checked="preferences.inapp_project_update"
|
||||
@update:checked="(val) => updatePreference('inapp_project_update', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>Comments added</Label>
|
||||
<Switch
|
||||
:checked="preferences.inapp_comment_added"
|
||||
@update:checked="(val) => updatePreference('inapp_comment_added', val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Email Digest -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Email Digest</h3>
|
||||
<p class="text-sm text-muted-foreground">Receive a summary of notifications</p>
|
||||
</div>
|
||||
<Switch
|
||||
:checked="preferences.email_digest_enabled"
|
||||
@update:checked="(val) => updatePreference('email_digest_enabled', val)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="preferences.email_digest_enabled" class="ml-6">
|
||||
<Label>Frequency</Label>
|
||||
<Select
|
||||
:model-value="preferences.email_digest_frequency"
|
||||
@update:model-value="(val) => updatePreference('email_digest_frequency', val)"
|
||||
>
|
||||
<SelectTrigger class="w-full mt-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { NotificationPreferences } from '@/types/notification'
|
||||
|
||||
const notificationsStore = useNotificationsStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const preferences = ref<NotificationPreferences | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPreferences()
|
||||
})
|
||||
|
||||
async function loadPreferences() {
|
||||
loading.value = true
|
||||
try {
|
||||
await notificationsStore.fetchPreferences()
|
||||
preferences.value = notificationsStore.preferences
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to load notification preferences'
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePreference(key: keyof NotificationPreferences, value: boolean | string) {
|
||||
if (!preferences.value) return
|
||||
|
||||
try {
|
||||
// Update local state immediately for better UX
|
||||
preferences.value = { ...preferences.value, [key]: value } as NotificationPreferences
|
||||
|
||||
// Update on server
|
||||
await notificationsStore.updatePreferences({ [key]: value })
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Preferences updated'
|
||||
})
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
await loadPreferences()
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to update preferences'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,665 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">Recovery Management</h2>
|
||||
<p class="text-muted-foreground">
|
||||
Manage and recover soft-deleted shots and assets
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="refreshData" :disabled="isLoading" variant="outline">
|
||||
<RefreshCw :class="{ 'animate-spin': isLoading }" class="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Project Filter -->
|
||||
<div class="space-y-2">
|
||||
<Label for="project-filter">Project</Label>
|
||||
<Select v-model="selectedProjectId">
|
||||
<SelectTrigger id="project-filter">
|
||||
<SelectValue placeholder="All Projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Projects</SelectItem>
|
||||
<SelectItem
|
||||
v-for="project in availableProjects.filter(p => p.id !== 0)"
|
||||
:key="project.id"
|
||||
:value="project.id.toString()"
|
||||
>
|
||||
{{ project.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Item Type Filter -->
|
||||
<div class="space-y-2">
|
||||
<Label for="type-filter">Item Type</Label>
|
||||
<Select v-model="selectedItemType">
|
||||
<SelectTrigger id="type-filter">
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="shots">Shots Only</SelectItem>
|
||||
<SelectItem value="assets">Assets Only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="space-y-2">
|
||||
<Label for="search">Search</Label>
|
||||
<Input
|
||||
id="search"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search by name..."
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
<span>Loading recovery data...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<div class="text-red-600 mb-2">{{ error }}</div>
|
||||
<Button @click="refreshData" variant="outline">Try Again</Button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Film class="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Deleted Shots</p>
|
||||
<p class="text-2xl font-bold">{{ filteredShots.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Package class="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Deleted Assets</p>
|
||||
<p class="text-2xl font-bold">{{ filteredAssets.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Database class="w-5 h-5 text-purple-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Items</p>
|
||||
<p class="text-2xl font-bold">{{ filteredShots.length + filteredAssets.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Deleted Shots -->
|
||||
<div v-if="shouldShowShots && filteredShots.length > 0">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center">
|
||||
<Film class="w-5 h-5 mr-2" />
|
||||
Deleted Shots ({{ filteredShots.length }})
|
||||
</h3>
|
||||
<div class="grid gap-4">
|
||||
<Card v-for="shot in filteredShots" :key="`shot-${shot.id}`" class="hover:shadow-md transition-shadow">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<h4 class="font-semibold">{{ shot.name }}</h4>
|
||||
<Badge variant="secondary">Shot</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-muted-foreground mb-4">
|
||||
<div>
|
||||
<span class="font-medium">Episode:</span> {{ shot.episode_name }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Project:</span> {{ shot.project_name }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Deleted:</span> {{ formatDate(shot.deleted_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">By:</span> {{ shot.deleted_by_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4 text-sm">
|
||||
<span class="flex items-center">
|
||||
<CheckSquare class="w-4 h-4 mr-1" />
|
||||
{{ shot.task_count }} tasks
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<FileText class="w-4 h-4 mr-1" />
|
||||
{{ shot.submission_count }} submissions
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<Paperclip class="w-4 h-4 mr-1" />
|
||||
{{ shot.attachment_count }} attachments
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<MessageSquare class="w-4 h-4 mr-1" />
|
||||
{{ shot.note_count }} notes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<Button
|
||||
@click="previewShotRecovery(shot)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="isRecovering"
|
||||
>
|
||||
<Eye class="w-4 h-4 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
@click="recoverShot(shot)"
|
||||
size="sm"
|
||||
:disabled="isRecovering"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4 mr-1" />
|
||||
Recover
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deleted Assets -->
|
||||
<div v-if="shouldShowAssets && filteredAssets.length > 0">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center">
|
||||
<Package class="w-5 h-5 mr-2" />
|
||||
Deleted Assets ({{ filteredAssets.length }})
|
||||
</h3>
|
||||
<div class="grid gap-4">
|
||||
<Card v-for="asset in filteredAssets" :key="`asset-${asset.id}`" class="hover:shadow-md transition-shadow">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<h4 class="font-semibold">{{ asset.name }}</h4>
|
||||
<Badge variant="outline">{{ asset.category }}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-muted-foreground mb-4">
|
||||
<div>
|
||||
<span class="font-medium">Category:</span> {{ asset.category }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Project:</span> {{ asset.project_name }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Deleted:</span> {{ formatDate(asset.deleted_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">By:</span> {{ asset.deleted_by_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4 text-sm">
|
||||
<span class="flex items-center">
|
||||
<CheckSquare class="w-4 h-4 mr-1" />
|
||||
{{ asset.task_count }} tasks
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<FileText class="w-4 h-4 mr-1" />
|
||||
{{ asset.submission_count }} submissions
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<Paperclip class="w-4 h-4 mr-1" />
|
||||
{{ asset.attachment_count }} attachments
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<MessageSquare class="w-4 h-4 mr-1" />
|
||||
{{ asset.note_count }} notes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<Button
|
||||
@click="previewAssetRecovery(asset)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="isRecovering"
|
||||
>
|
||||
<Eye class="w-4 h-4 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
@click="recoverAsset(asset)"
|
||||
size="sm"
|
||||
:disabled="isRecovering"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4 mr-1" />
|
||||
Recover
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredShots.length === 0 && filteredAssets.length === 0" class="text-center py-12">
|
||||
<Trash2 class="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No items available for recovery</h3>
|
||||
<p class="text-muted-foreground">
|
||||
{{ searchQuery || selectedProjectId ? 'Try adjusting your filters' : 'There are no items currently available for recovery' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recovery Preview Dialog -->
|
||||
<Dialog v-model:open="showPreviewDialog">
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Recovery Preview</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review what will be recovered before proceeding
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="previewInfo" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
<span class="font-medium">Name:</span> {{ previewInfo.name }}
|
||||
</div>
|
||||
<div v-if="previewInfo.episode_name">
|
||||
<span class="font-medium">Episode:</span> {{ previewInfo.episode_name }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Project:</span> {{ previewInfo.project_name }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Deleted:</span> {{ formatDate(previewInfo.deleted_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">Items to be recovered:</h4>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span>Tasks:</span>
|
||||
<Badge variant="secondary">{{ previewInfo.task_count }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Submissions:</span>
|
||||
<Badge variant="secondary">{{ previewInfo.submission_count }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Attachments:</span>
|
||||
<Badge variant="secondary">{{ previewInfo.attachment_count }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Notes:</span>
|
||||
<Badge variant="secondary">{{ previewInfo.note_count }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Reviews:</span>
|
||||
<Badge variant="secondary">{{ previewInfo.review_count }}</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Files:</span>
|
||||
<Badge variant="secondary">{{ previewInfo.file_count }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<CheckCircle class="w-5 h-5 text-green-600" />
|
||||
<span class="text-sm text-green-800">
|
||||
Files are preserved and will be restored with the data
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="showPreviewDialog = false" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmRecovery"
|
||||
:disabled="isRecovering"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4 mr-1" />
|
||||
Confirm Recovery
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Recovery Confirmation Dialog -->
|
||||
<Dialog v-model:open="showConfirmDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Recovery</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to recover this item and all its related data?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="itemToRecover" class="space-y-4">
|
||||
<div class="p-4 bg-muted rounded-lg">
|
||||
<div class="font-semibold">{{ itemToRecover.name }}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ 'episode_name' in itemToRecover ? itemToRecover.episode_name : itemToRecover.category }} • {{ itemToRecover.project_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted-foreground">
|
||||
This action will restore the item and all its related data to active status.
|
||||
This cannot be undone.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="showConfirmDialog = false" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="executeRecovery"
|
||||
:disabled="isRecovering"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4 mr-1" />
|
||||
{{ isRecovering ? 'Recovering...' : 'Recover' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
RefreshCw,
|
||||
Film,
|
||||
Package,
|
||||
Database,
|
||||
CheckSquare,
|
||||
FileText,
|
||||
Paperclip,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
CheckCircle
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useProjectsStore } from '@/stores/projects'
|
||||
import { recoveryService, type DeletedShot, type DeletedAsset, type RecoveryInfo } from '@/services/recovery'
|
||||
|
||||
// UI Components
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const isRecovering = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const deletedShots = ref<DeletedShot[]>([])
|
||||
const deletedAssets = ref<DeletedAsset[]>([])
|
||||
|
||||
// Filters
|
||||
const selectedProjectId = ref<string>('')
|
||||
const selectedItemType = ref<string>('all')
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
// Dialog state
|
||||
const showPreviewDialog = ref(false)
|
||||
const showConfirmDialog = ref(false)
|
||||
const previewInfo = ref<RecoveryInfo | null>(null)
|
||||
const itemToRecover = ref<DeletedShot | DeletedAsset | null>(null)
|
||||
const recoveryType = ref<'shot' | 'asset'>('shot')
|
||||
|
||||
// Stores
|
||||
const projectsStore = useProjectsStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
// Computed
|
||||
const availableProjects = computed(() => projectsStore.availableProjects)
|
||||
|
||||
const filteredShots = computed(() => {
|
||||
let shots = deletedShots.value
|
||||
|
||||
// Filter by project
|
||||
if (selectedProjectId.value) {
|
||||
const projectId = parseInt(selectedProjectId.value)
|
||||
const selectedProject = projectsStore.getProjectById(projectId)
|
||||
if (selectedProject) {
|
||||
shots = shots.filter(shot => shot.project_name === selectedProject.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
shots = shots.filter(shot =>
|
||||
shot.name.toLowerCase().includes(query) ||
|
||||
shot.episode_name.toLowerCase().includes(query) ||
|
||||
shot.project_name.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return shots
|
||||
})
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
let assets = deletedAssets.value
|
||||
|
||||
// Filter by project
|
||||
if (selectedProjectId.value) {
|
||||
const projectId = parseInt(selectedProjectId.value)
|
||||
const selectedProject = projectsStore.getProjectById(projectId)
|
||||
if (selectedProject) {
|
||||
assets = assets.filter(asset => asset.project_name === selectedProject.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
assets = assets.filter(asset =>
|
||||
asset.name.toLowerCase().includes(query) ||
|
||||
asset.category.toLowerCase().includes(query) ||
|
||||
asset.project_name.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return assets
|
||||
})
|
||||
|
||||
const shouldShowShots = computed(() =>
|
||||
selectedItemType.value === 'all' || selectedItemType.value === 'shots'
|
||||
)
|
||||
|
||||
const shouldShowAssets = computed(() =>
|
||||
selectedItemType.value === 'all' || selectedItemType.value === 'assets'
|
||||
)
|
||||
|
||||
// Methods
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadDeletedItems = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const projectId = selectedProjectId.value ? parseInt(selectedProjectId.value) : undefined
|
||||
|
||||
const [shots, assets] = await Promise.all([
|
||||
recoveryService.getDeletedShots(projectId),
|
||||
recoveryService.getDeletedAssets(projectId)
|
||||
])
|
||||
|
||||
deletedShots.value = shots
|
||||
deletedAssets.value = assets
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to load recovery data'
|
||||
console.error('Failed to load recovery data:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
loadDeletedItems()
|
||||
}
|
||||
|
||||
const previewShotRecovery = async (shot: DeletedShot) => {
|
||||
try {
|
||||
previewInfo.value = await recoveryService.previewShotRecovery(shot.id)
|
||||
recoveryType.value = 'shot'
|
||||
showPreviewDialog.value = true
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err.response?.data?.detail || 'Failed to load recovery preview',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const previewAssetRecovery = async (asset: DeletedAsset) => {
|
||||
try {
|
||||
previewInfo.value = await recoveryService.previewAssetRecovery(asset.id)
|
||||
recoveryType.value = 'asset'
|
||||
showPreviewDialog.value = true
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err.response?.data?.detail || 'Failed to load recovery preview',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const recoverShot = (shot: DeletedShot) => {
|
||||
itemToRecover.value = shot
|
||||
recoveryType.value = 'shot'
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
const recoverAsset = (asset: DeletedAsset) => {
|
||||
itemToRecover.value = asset
|
||||
recoveryType.value = 'asset'
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
const confirmRecovery = () => {
|
||||
showPreviewDialog.value = false
|
||||
if (previewInfo.value) {
|
||||
if (recoveryType.value === 'shot' && previewInfo.value.shot_id) {
|
||||
const shot = deletedShots.value.find(s => s.id === previewInfo.value!.shot_id)
|
||||
if (shot) {
|
||||
recoverShot(shot)
|
||||
}
|
||||
} else if (recoveryType.value === 'asset' && previewInfo.value.asset_id) {
|
||||
const asset = deletedAssets.value.find(a => a.id === previewInfo.value!.asset_id)
|
||||
if (asset) {
|
||||
recoverAsset(asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executeRecovery = async () => {
|
||||
if (!itemToRecover.value) return
|
||||
|
||||
try {
|
||||
isRecovering.value = true
|
||||
|
||||
let result
|
||||
if (recoveryType.value === 'shot') {
|
||||
result = await recoveryService.recoverShot(itemToRecover.value.id)
|
||||
} else {
|
||||
result = await recoveryService.recoverAsset(itemToRecover.value.id)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Recovery Successful',
|
||||
description: `${result.name} and all related data have been recovered`,
|
||||
})
|
||||
|
||||
// Remove from deleted lists
|
||||
if (recoveryType.value === 'shot') {
|
||||
deletedShots.value = deletedShots.value.filter(s => s.id !== itemToRecover.value!.id)
|
||||
} else {
|
||||
deletedAssets.value = deletedAssets.value.filter(a => a.id !== itemToRecover.value!.id)
|
||||
}
|
||||
|
||||
showConfirmDialog.value = false
|
||||
itemToRecover.value = null
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Recovery Failed',
|
||||
description: err.response?.data?.detail || 'Failed to recover item',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isRecovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for project filter changes to reload data
|
||||
watch(selectedProjectId, () => {
|
||||
loadDeletedItems()
|
||||
})
|
||||
|
||||
// Initialize
|
||||
onMounted(async () => {
|
||||
await projectsStore.fetchProjects()
|
||||
await loadDeletedItems()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Upload class="h-4 w-4" />
|
||||
<span>Max file size: {{ formatFileSize(uploadLimitBytes) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const uploadLimitBytes = computed(() => settingsStore.uploadLimitBytes)
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Fetch upload limit if not already loaded
|
||||
if (!settingsStore.uploadLimit) {
|
||||
settingsStore.fetchUploadLimit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Upload Location Configuration</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Configure the storage location for project file uploads
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload Data Location -->
|
||||
<div class="space-y-2">
|
||||
<Label for="upload_location">Upload Data Location</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="upload_location"
|
||||
v-model="uploadLocation"
|
||||
placeholder="e.g., /mnt/projects/project-name/uploads"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="icon" @click="clearLocation" v-if="uploadLocation">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Specify the file system path where uploaded files will be stored for this project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<Info class="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div class="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<strong>Note:</strong> This path should be accessible by the server and have appropriate write permissions.
|
||||
</p>
|
||||
<p>
|
||||
If left empty, the system will use the default upload location configured in the server settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example Paths -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">Example Paths</Label>
|
||||
<div class="space-y-1 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="font-mono">Windows</Badge>
|
||||
<code>D:\Projects\ProjectName\Uploads</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="font-mono">Linux/Mac</Badge>
|
||||
<code>/mnt/storage/projects/project-name/uploads</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="font-mono">Network</Badge>
|
||||
<code>\\server\projects\project-name\uploads</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center justify-end gap-2 pt-4">
|
||||
<Button variant="outline" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="handleSave" :disabled="isSaving">
|
||||
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Save Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Info, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface Props {
|
||||
initialLocation?: string
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
initialLocation: '',
|
||||
isSaving: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [location: string]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
// State
|
||||
const uploadLocation = ref(props.initialLocation || '')
|
||||
|
||||
// Methods
|
||||
const clearLocation = () => {
|
||||
uploadLocation.value = ''
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
emit('save', uploadLocation.value)
|
||||
}
|
||||
|
||||
// Watch for prop changes
|
||||
watch(() => props.initialLocation, (newVal) => {
|
||||
uploadLocation.value = newVal || ''
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Project Context Display -->
|
||||
<div v-if="projectContext" class="p-3 bg-muted/50 rounded-md border">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div class="font-medium text-muted-foreground">Project:</div>
|
||||
<div class="font-semibold">{{ projectContext.name }}</div>
|
||||
<div v-if="episodeContext" class="text-muted-foreground">
|
||||
• Episode: {{ episodeContext.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="projectContext.code_name" class="text-xs text-muted-foreground mt-1">
|
||||
Code: {{ projectContext.code_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Error Display -->
|
||||
<div v-if="validationError" class="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-destructive">{{ validationError.title }}</div>
|
||||
<div class="text-sm text-destructive/80">{{ validationError.message }}</div>
|
||||
<div v-if="validationError.suggestion" class="text-xs text-muted-foreground">
|
||||
{{ validationError.suggestion }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Naming Pattern -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Naming Pattern</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Configure how shots will be named automatically
|
||||
<span v-if="projectContext" class="block mt-1 text-xs">
|
||||
All shot names must be unique within project "{{ projectContext.name }}"
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name_prefix">Name Prefix</Label>
|
||||
<Input
|
||||
id="name_prefix"
|
||||
v-model="formData.name_prefix"
|
||||
placeholder="SH"
|
||||
:disabled="isLoading"
|
||||
:class="validationError?.field === 'name_prefix' ? 'border-destructive focus-visible:ring-destructive' : ''"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="start_number">Start Number</Label>
|
||||
<Input
|
||||
id="start_number"
|
||||
v-model.number="formData.start_number"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="10"
|
||||
:disabled="isLoading"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="shot_count">Number of Shots</Label>
|
||||
<Input
|
||||
id="shot_count"
|
||||
v-model.number="formData.shot_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
placeholder="10"
|
||||
:disabled="isLoading"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="number_padding">Number Padding</Label>
|
||||
<Select v-model="formData.number_padding" :disabled="isLoading">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select padding" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2 digits (01, 02, 03)</SelectItem>
|
||||
<SelectItem value="3">3 digits (010, 020, 030)</SelectItem>
|
||||
<SelectItem value="4">4 digits (0010, 0020, 0030)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="p-3 bg-muted rounded-md">
|
||||
<Label class="text-sm font-medium">Preview:</Label>
|
||||
<div class="mt-1 text-sm text-muted-foreground">
|
||||
<span v-if="previewNames.length > 0">
|
||||
{{ previewNames.slice(0, 3).join(', ') }}
|
||||
<span v-if="previewNames.length > 3">... ({{ previewNames.length - 3 }} more)</span>
|
||||
</span>
|
||||
<span v-else class="italic">Enter values to see preview</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frame Range -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Frame Range</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Set the default frame range for all shots
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="frame_start">Start Frame</Label>
|
||||
<Input
|
||||
id="frame_start"
|
||||
v-model.number="formData.frame_start"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1001"
|
||||
:disabled="isLoading"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="frame_end">End Frame</Label>
|
||||
<Input
|
||||
id="frame_end"
|
||||
v-model.number="formData.frame_end"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1100"
|
||||
:disabled="isLoading"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frame Count Display -->
|
||||
<div v-if="frameCount > 0" class="text-sm text-muted-foreground">
|
||||
Each shot will have {{ frameCount }} frames
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Template -->
|
||||
<div class="space-y-2">
|
||||
<Label for="description_template">Description Template (Optional)</Label>
|
||||
<Textarea
|
||||
id="description_template"
|
||||
v-model="formData.description_template"
|
||||
placeholder="Shot {shot_name} - {shot_number}"
|
||||
:disabled="isLoading"
|
||||
rows="2"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Use {shot_name} and {shot_number} as placeholders
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Task Creation Options -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
id="create_default_tasks"
|
||||
v-model="formData.create_default_tasks"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<Label for="create_default_tasks" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Create default tasks for each shot
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground ml-6">
|
||||
Automatically create layout, animation, lighting, and compositing tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="p-4 bg-muted rounded-md space-y-2">
|
||||
<Label class="text-sm font-medium">Summary:</Label>
|
||||
<div class="text-sm text-muted-foreground space-y-1">
|
||||
<div>• {{ formData.shot_count }} shots will be created</div>
|
||||
<div v-if="formData.create_default_tasks">• {{ formData.shot_count * 4 }} tasks will be created (4 per shot)</div>
|
||||
<div>• Frame range: {{ formData.frame_start }}-{{ formData.frame_end }} ({{ frameCount }} frames each)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isLoading || !isFormValid"
|
||||
>
|
||||
<div v-if="isLoading" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Creating Shots...
|
||||
</div>
|
||||
<span v-else>Create {{ formData.shot_count }} Shots</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { AlertCircle } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { BulkShotCreate } from '@/services/shot'
|
||||
import type { Project } from '@/services/project'
|
||||
import type { Episode } from '@/services/episode'
|
||||
|
||||
interface ValidationErrorDisplay {
|
||||
title: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
field?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean
|
||||
projectContext?: Project
|
||||
episodeContext?: Episode
|
||||
validationError?: ValidationErrorDisplay | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', data: BulkShotCreate): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Form data
|
||||
const formData = ref({
|
||||
name_prefix: 'SH',
|
||||
shot_count: 10,
|
||||
start_number: 10,
|
||||
number_padding: 3,
|
||||
frame_start: 1001,
|
||||
frame_end: 1100,
|
||||
description_template: '',
|
||||
create_default_tasks: true
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
formData.value.name_prefix.trim() !== '' &&
|
||||
formData.value.shot_count > 0 &&
|
||||
formData.value.shot_count <= 1000 &&
|
||||
formData.value.start_number > 0 &&
|
||||
formData.value.frame_start > 0 &&
|
||||
formData.value.frame_end > 0 &&
|
||||
formData.value.frame_end >= formData.value.frame_start
|
||||
)
|
||||
})
|
||||
|
||||
const frameCount = computed(() => {
|
||||
if (formData.value.frame_end >= formData.value.frame_start) {
|
||||
return formData.value.frame_end - formData.value.frame_start + 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const previewNames = computed(() => {
|
||||
if (!formData.value.name_prefix || formData.value.shot_count <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const names = []
|
||||
for (let i = 0; i < Math.min(formData.value.shot_count, 10); i++) {
|
||||
const shotNumber = formData.value.start_number + i
|
||||
const paddedNumber = shotNumber.toString().padStart(formData.value.number_padding, '0')
|
||||
names.push(`${formData.value.name_prefix}${paddedNumber}`)
|
||||
}
|
||||
return names
|
||||
})
|
||||
|
||||
// Methods
|
||||
const handleSubmit = () => {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
// Clear any existing validation errors
|
||||
emit('clear-error')
|
||||
|
||||
const data: BulkShotCreate = {
|
||||
name_prefix: formData.value.name_prefix.trim(),
|
||||
shot_count: formData.value.shot_count,
|
||||
start_number: formData.value.start_number,
|
||||
number_padding: formData.value.number_padding,
|
||||
frame_start: formData.value.frame_start,
|
||||
frame_end: formData.value.frame_end,
|
||||
description_template: formData.value.description_template.trim() || undefined,
|
||||
create_default_tasks: formData.value.create_default_tasks
|
||||
}
|
||||
|
||||
emit('submit', data)
|
||||
}
|
||||
|
||||
// Clear validation error when form data changes
|
||||
watch(
|
||||
() => formData.value.name_prefix,
|
||||
() => {
|
||||
if (props.validationError?.field === 'name_prefix') {
|
||||
emit('clear-error')
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="relative flex items-center gap-1" v-memo="[currentStatusId, isUpdating, isLoadingStatuses, assignedUserId]" data-testid="editable-task-status">
|
||||
<!-- Status Selector -->
|
||||
<Select
|
||||
:model-value="currentStatusId"
|
||||
@update:model-value="handleStatusChange"
|
||||
:disabled="isUpdating || isLoadingStatuses"
|
||||
>
|
||||
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
|
||||
:style="{ backgroundColor: currentStatusObject.color }"
|
||||
>
|
||||
<SelectValue>
|
||||
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="statusOption in allStatusOptions"
|
||||
:key="statusOption.id"
|
||||
:value="statusOption.id"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Color indicator -->
|
||||
<!-- <div
|
||||
v-if="statusOption.color"
|
||||
class="w-3 h-3 rounded-full border border-border"
|
||||
:style="{ backgroundColor: statusOption.color }"
|
||||
/> -->
|
||||
<TaskStatusBadge :status="statusOption" compact />
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- User Assignment Button -->
|
||||
<div @click.stop>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0 hover:bg-accent relative z-10"
|
||||
:disabled="isUpdating"
|
||||
@click="ensureMembersLoaded"
|
||||
>
|
||||
<Avatar class="h-4 w-4" v-if="assignedUser">
|
||||
<AvatarImage :src="getAvatarUrl(assignedUser.user_id)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(assignedUser) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<User class="h-3 w-3" v-else />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-64 p-2 z-50" align="start" side="bottom" :side-offset="4">
|
||||
<div class="space-y-2">
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">Assign Task</div>
|
||||
|
||||
<!-- Debug info -->
|
||||
<div class="px-2 py-1 text-xs text-muted-foreground">
|
||||
Project ID: {{ projectId }}, Members: {{ projectMembers.length }}
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoadingMembers" class="flex items-center justify-center py-4">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<span class="ml-2 text-sm">Loading members...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="projectMembers.length === 0" class="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||
No project members found
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
@click="loadProjectMembers"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Content when members are loaded -->
|
||||
<template v-else>
|
||||
<!-- Unassign option -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start"
|
||||
@click="handleAssignUser(null)"
|
||||
:disabled="isAssigning"
|
||||
>
|
||||
<UserX class="h-4 w-4 mr-2" />
|
||||
Unassign
|
||||
</Button>
|
||||
|
||||
<!-- Project members list -->
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<Button
|
||||
v-for="member in projectMembers"
|
||||
:key="member.user_id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start"
|
||||
@click="handleAssignUser(member.user_id)"
|
||||
:disabled="isAssigning"
|
||||
>
|
||||
<Avatar class="h-4 w-4 mr-2">
|
||||
<AvatarImage :src="getAvatarUrl(member.user_id)" />
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-sm">{{ member.user_first_name }} {{ member.user_last_name }}</span>
|
||||
<span class="text-xs text-muted-foreground" v-if="member.department_role">{{ formatDepartmentRole(member.department_role) }}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div
|
||||
v-if="isUpdating || isLoadingStatuses"
|
||||
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { User, UserX } from 'lucide-vue-next'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/shot'
|
||||
import { taskService } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
is_system?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
shotId: number
|
||||
taskType: string
|
||||
status: TaskStatus | string
|
||||
taskId?: number | null
|
||||
projectId: number
|
||||
assignedUserId?: number | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'status-updated', shotId: number, taskType: string, newStatus: string): void
|
||||
(e: 'assignment-updated', shotId: number, taskType: string, userId: number | null): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isAssigning = ref(false)
|
||||
const isLoadingMembers = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
|
||||
// Get loading state from store
|
||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||
|
||||
// Get all status options from store
|
||||
const allStatusOptions = computed(() => taskStatusesStore.getAllStatusOptions(props.projectId))
|
||||
|
||||
// Get current status ID (handle both TaskStatus enum and custom status strings)
|
||||
const currentStatusId = computed(() => {
|
||||
if (typeof props.status === 'string') {
|
||||
return props.status
|
||||
}
|
||||
return props.status as string
|
||||
})
|
||||
|
||||
// Get current status object for display using store
|
||||
const currentStatusObject = computed((): StatusOption => {
|
||||
const statusFromStore = taskStatusesStore.getStatusById(props.projectId, currentStatusId.value)
|
||||
if (statusFromStore) {
|
||||
return {
|
||||
id: statusFromStore.id,
|
||||
name: statusFromStore.name,
|
||||
color: statusFromStore.color,
|
||||
is_system: 'is_system' in statusFromStore ? statusFromStore.is_system : false
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to current status as-is
|
||||
return {
|
||||
id: currentStatusId.value,
|
||||
name: formatStatusName(currentStatusId.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Get assigned user info
|
||||
const assignedUserId = computed(() => props.assignedUserId)
|
||||
const assignedUser = computed(() => {
|
||||
if (!assignedUserId.value) return null
|
||||
return projectMembers.value.find(member => member.user_id === assignedUserId.value) || null
|
||||
})
|
||||
|
||||
// Format status name for display
|
||||
const formatStatusName = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'not_started':
|
||||
return 'Not Started'
|
||||
case 'in_progress':
|
||||
return 'In Progress'
|
||||
case 'submitted':
|
||||
return 'Submitted'
|
||||
case 'approved':
|
||||
return 'Approved'
|
||||
case 'retake':
|
||||
return 'Retake'
|
||||
default:
|
||||
// Convert snake_case to Title Case
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Format department role for display
|
||||
const formatDepartmentRole = (role: string): string => {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1)
|
||||
}
|
||||
|
||||
// Get user initials
|
||||
const getUserInitials = (member: ProjectMember): string => {
|
||||
const first = member.user_first_name?.charAt(0) || ''
|
||||
const last = member.user_last_name?.charAt(0) || ''
|
||||
return (first + last).toUpperCase()
|
||||
}
|
||||
|
||||
// Get avatar URL
|
||||
const getAvatarUrl = (userId: number): string => {
|
||||
return `https://api.dicebear.com/7.x/initials/svg?seed=${userId}`
|
||||
}
|
||||
|
||||
// Fetch custom statuses for the project using store
|
||||
const fetchStatuses = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load project members
|
||||
const loadProjectMembers = async () => {
|
||||
if (projectMembers.value.length > 0) return // Already loaded
|
||||
|
||||
isLoadingMembers.value = true
|
||||
try {
|
||||
console.log('Loading project members for project:', props.projectId)
|
||||
projectMembers.value = await projectService.getProjectMembers(props.projectId)
|
||||
console.log('Loaded project members:', projectMembers.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load project members:', error)
|
||||
} finally {
|
||||
isLoadingMembers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure members are loaded when popover is about to open
|
||||
const ensureMembersLoaded = () => {
|
||||
console.log('Ensuring project members are loaded')
|
||||
if (projectMembers.value.length === 0) {
|
||||
console.log('Loading project members on button click')
|
||||
loadProjectMembers()
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatusId: any) => {
|
||||
if (!newStatusId || newStatusId === currentStatusId.value) return
|
||||
|
||||
const statusId = newStatusId as string
|
||||
|
||||
isUpdating.value = true
|
||||
|
||||
try {
|
||||
let taskId = props.taskId
|
||||
|
||||
// If no task exists, create one first
|
||||
if (!taskId) {
|
||||
const newTask = await taskService.createShotTask(props.shotId, props.taskType)
|
||||
taskId = newTask.task_id
|
||||
}
|
||||
|
||||
// Update the task status
|
||||
if (taskId) {
|
||||
await taskService.updateTaskStatus(taskId, statusId as TaskStatus)
|
||||
emit('status-updated', props.shotId, props.taskType, statusId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error)
|
||||
// Revert the status change by emitting the original status
|
||||
// This will cause the parent component to refresh the data
|
||||
emit('status-updated', props.shotId, props.taskType, currentStatusId.value)
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignUser = async (userId: number | null) => {
|
||||
isAssigning.value = true
|
||||
|
||||
try {
|
||||
let taskId = props.taskId
|
||||
|
||||
// If no task exists, create one first
|
||||
if (!taskId) {
|
||||
const newTask = await taskService.createShotTask(props.shotId, props.taskType)
|
||||
taskId = newTask.task_id
|
||||
}
|
||||
|
||||
// Assign or unassign the task
|
||||
if (taskId) {
|
||||
if (userId) {
|
||||
// Use the assignment endpoint for assigning to a user
|
||||
await taskService.assignTask(taskId, userId)
|
||||
} else {
|
||||
// Use the update endpoint for unassignment (set assigned_user_id to 0)
|
||||
await taskService.updateTask(taskId, { assigned_user_id: 0 })
|
||||
}
|
||||
emit('assignment-updated', props.shotId, props.taskType, userId)
|
||||
}
|
||||
|
||||
// Note: Popover will close naturally when clicking outside or on assignment
|
||||
} catch (error) {
|
||||
console.error('Failed to assign task:', error)
|
||||
} finally {
|
||||
isAssigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch statuses on mount
|
||||
onMounted(() => {
|
||||
fetchStatuses()
|
||||
// Preload project members to ensure they're available when needed
|
||||
loadProjectMembers()
|
||||
})
|
||||
|
||||
// Refetch statuses when projectId changes
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
// Clear project members when project changes
|
||||
projectMembers.value = []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,915 @@
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<!-- Main Content -->
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar - Sticky -->
|
||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<ShotTableToolbar
|
||||
:view-mode="viewMode"
|
||||
:episode-filter="episodeFilter"
|
||||
:search="searchQuery"
|
||||
:column-visibility="columnVisibility"
|
||||
:episodes="episodes"
|
||||
:all-task-types="allTaskTypes"
|
||||
:project-id="projectId"
|
||||
:selected-shot="selectedShot"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@update:episode-filter="handleEpisodeFilterChange"
|
||||
@update:search="searchQuery = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@task-status-filter-changed="handleTaskStatusFilter"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
@bulk-create="showBulkCreateDialog = true"
|
||||
@create-shot="showCreateDialog = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
|
||||
<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 shots...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12 px-4 sm:px-6">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load shots</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="loadShots" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="filteredShots.length === 0 && !searchQuery" class="text-center py-12 px-4 sm:px-6">
|
||||
<Camera class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No shots yet</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{{ episodeFilter !== null ? 'This episode has no shots yet' : 'No shots found' }}
|
||||
</p>
|
||||
<div class="flex justify-center gap-2">
|
||||
<Button @click="showCreateDialog = true">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Create Shot
|
||||
</Button>
|
||||
<Button @click="showBulkCreateDialog = true" variant="outline">
|
||||
<Layers class="h-4 w-4 mr-2" />
|
||||
Bulk Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results State -->
|
||||
<div v-else-if="filteredShots.length === 0" class="text-center py-12 px-4 sm:px-6">
|
||||
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No shots found</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
Try adjusting your search criteria
|
||||
</p>
|
||||
<Button @click="clearSearch" variant="outline">
|
||||
Clear Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Shots Grid/List/Table -->
|
||||
<div v-else>
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
v-if="viewMode === 'grid'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 px-4 sm:px-6"
|
||||
>
|
||||
<ShotCard
|
||||
v-for="shot in filteredShots"
|
||||
:key="shot.id"
|
||||
:shot="shot"
|
||||
@select="selectShot"
|
||||
@edit="editShot"
|
||||
@delete="deleteShot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else-if="viewMode === 'list'" class="space-y-2 px-4 sm:px-6">
|
||||
<div
|
||||
v-for="shot in filteredShots"
|
||||
:key="shot.id"
|
||||
class="flex items-center gap-4 p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Camera class="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium truncate">{{ shot.name }}</h3>
|
||||
<Badge :variant="getStatusVariant(shot.status)" class="text-xs">
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground truncate">
|
||||
Frames {{ shot.frame_start }}-{{ shot.frame_end }} ({{ shot.frame_end - shot.frame_start + 1 }} frames)
|
||||
<span v-if="shot.description"> • {{ shot.description }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground flex-shrink-0">
|
||||
<ListTodo class="h-4 w-4" />
|
||||
<span>{{ shot.task_count }}</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild @click.stop>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click.stop="editShot(shot)">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit Shot
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click.stop="deleteShot(shot)"
|
||||
class="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete Shot
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table View -->
|
||||
<ShotsDataTable
|
||||
v-else-if="viewMode === 'table'"
|
||||
:columns="shotColumns"
|
||||
:data="filteredShots"
|
||||
:sorting="sorting"
|
||||
:column-visibility="columnVisibility"
|
||||
:all-task-types="allTaskTypes"
|
||||
@update:sorting="sorting = $event"
|
||||
@update:column-visibility="handleColumnVisibilityChange"
|
||||
@update:rowSelection="handleRowSelectionChange"
|
||||
@row-click="handleRowClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel (Overlay - Desktop) with Slide Animation -->
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-300 ease-out"
|
||||
enter-from-class="translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transition-transform duration-300 ease-in"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel"
|
||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||
>
|
||||
<ShotDetailPanel
|
||||
:project-id="projectId"
|
||||
:shot-id="selectedShot.id"
|
||||
@edit="editShot"
|
||||
@delete="deleteShot"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
@close="closeDetailPanel"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Detail Sheet -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<ShotDetailPanel
|
||||
v-if="selectedShot"
|
||||
:project-id="projectId"
|
||||
:shot-id="selectedShot.id"
|
||||
@edit="editShot"
|
||||
@delete="deleteShot"
|
||||
@create-task="handleCreateTask"
|
||||
@select-task="handleSelectTask"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
|
||||
<!-- Dialogs -->
|
||||
<div class="space-y-4">
|
||||
<!-- Create Shot Dialog -->
|
||||
<Dialog v-model:open="showCreateDialog">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Shot</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new shot to the selected episode.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ShotForm
|
||||
:is-loading="isCreating"
|
||||
:project-context="projectContext || undefined"
|
||||
:episode-context="episodes.find(e => e.id === (episodeFilter || episodes[0]?.id))"
|
||||
:validation-error="validationError"
|
||||
@submit="handleCreateShot"
|
||||
@cancel="showCreateDialog = false"
|
||||
@clear-error="clearValidationError"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Bulk Create Dialog -->
|
||||
<Dialog v-model:open="showBulkCreateDialog">
|
||||
<DialogContent class="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Create Shots</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create multiple shots at once with automatic naming and task generation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<BulkShotForm
|
||||
:is-loading="isBulkCreating"
|
||||
:project-context="projectContext || undefined"
|
||||
:episode-context="episodes.find(e => e.id === (episodeFilter || episodes[0]?.id))"
|
||||
:validation-error="validationError"
|
||||
@submit="handleBulkCreateShots"
|
||||
@cancel="showBulkCreateDialog = false"
|
||||
@clear-error="clearValidationError"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Edit Shot Dialog -->
|
||||
<Dialog v-model:open="showEditDialog">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Shot</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the shot information and settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ShotForm
|
||||
:shot="selectedShot ?? undefined"
|
||||
:is-loading="isUpdating"
|
||||
:project-context="projectContext || undefined"
|
||||
:episode-context="episodes.find(e => e.id === selectedShot?.episode_id)"
|
||||
:validation-error="validationError"
|
||||
@submit="handleUpdateShot"
|
||||
@cancel="showEditDialog = false"
|
||||
@clear-error="clearValidationError"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ShotDeleteConfirmDialog
|
||||
v-if="deletionInfo && shotToDelete"
|
||||
:open="showDeleteDialog"
|
||||
:shot-id="shotToDelete.id"
|
||||
:shot-name="deletionInfo.shot_name"
|
||||
@update:open="showDeleteDialog = $event"
|
||||
@confirm-delete="handleDeleteShot"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, shallowRef, markRaw, nextTick } from 'vue'
|
||||
import {
|
||||
Search, Plus, Camera, AlertCircle, RefreshCw,
|
||||
Layers, MoreHorizontal, Edit, ListTodo, Trash2
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
} from '@/components/ui/sheet'
|
||||
import ShotDeleteConfirmDialog from './ShotDeleteConfirmDialog.vue'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import ShotCard from './ShotCard.vue'
|
||||
import ShotForm from './ShotForm.vue'
|
||||
import BulkShotForm from './BulkShotForm.vue'
|
||||
import ShotDetailPanel from './ShotDetailPanel.vue'
|
||||
import ShotsDataTable from './ShotsDataTable.vue'
|
||||
import ShotTableToolbar from './ShotTableToolbar.vue'
|
||||
import { createShotColumns, type ShotColumnMeta } from './columns'
|
||||
import { shotService, ShotStatus, TaskStatus, type Shot, type ShotCreate, type ShotUpdate, type BulkShotCreate, type ShotDeletionInfo, type ProjectValidationError } from '@/services/shot'
|
||||
import { episodeService, type Episode } from '@/services/episode'
|
||||
import { projectService, type Project } from '@/services/project'
|
||||
import { customTaskTypeService } from '@/services/customTaskType'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import { useDetailPanel } from '@/composables/useDetailPanel'
|
||||
import type { SortingState, VisibilityState } from '@tanstack/vue-table'
|
||||
|
||||
interface ValidationErrorDisplay {
|
||||
title: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
field?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
selectedEpisodeId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Composables
|
||||
const { toast } = useToast()
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
// Detail panel composable
|
||||
const {
|
||||
isDetailPanelEnabled,
|
||||
isDetailPanelVisible,
|
||||
selectedEntity: selectedShot,
|
||||
showMobileDetail,
|
||||
showPanel,
|
||||
toggleDetailPanelEnabled,
|
||||
closeDetailPanel,
|
||||
selectEntity: selectShot,
|
||||
handleRowClick
|
||||
} = useDetailPanel<Shot>({
|
||||
isDialogOpen: () => showCreateDialog.value || showBulkCreateDialog.value || showEditDialog.value || showDeleteDialog.value,
|
||||
sessionStorageKey: 'shotBrowser.detailPanelEnabled'
|
||||
})
|
||||
|
||||
// Reactive state
|
||||
const shots = ref<Shot[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const viewMode = ref<'grid' | 'list' | 'table'>('table')
|
||||
const searchQuery = ref('')
|
||||
const shotToDelete = ref<Shot | null>(null)
|
||||
const showCreateDialog = ref(false)
|
||||
const showBulkCreateDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const deletionInfo = ref<ShotDeletionInfo | null>(null)
|
||||
const isCreating = ref(false)
|
||||
const isBulkCreating = ref(false)
|
||||
const isUpdating = ref(false)
|
||||
const episodes = ref<Episode[]>([])
|
||||
const episodeFilter = ref<number | null>(null)
|
||||
const allTaskTypes = ref<string[]>([])
|
||||
const taskStatusFilter = ref('')
|
||||
const projectContext = ref<Project | null>(null)
|
||||
const validationError = ref<ValidationErrorDisplay | null>(null)
|
||||
|
||||
// TanStack Table state
|
||||
const sorting = ref<SortingState>([])
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
const rowSelection = ref<Record<string, boolean>>({})
|
||||
|
||||
// Computed for selected count
|
||||
const selectedCount = computed(() => {
|
||||
return Object.keys(rowSelection.value).length
|
||||
})
|
||||
const initializeColumnVisibility = () => {
|
||||
const stored = sessionStorage.getItem('shotBrowser.columnVisibility')
|
||||
if (stored) {
|
||||
try {
|
||||
columnVisibility.value = JSON.parse(stored)
|
||||
} catch {
|
||||
// Fall back to defaults
|
||||
columnVisibility.value = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeColumnVisibility()
|
||||
|
||||
// Computed properties
|
||||
const filteredShots = computed(() => {
|
||||
let filtered = [...shots.value]
|
||||
|
||||
// Filter by episode
|
||||
if (episodeFilter.value !== null) {
|
||||
filtered = filtered.filter(shot => shot.episode_id === episodeFilter.value)
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
filtered = filtered.filter(shot =>
|
||||
shot.name.toLowerCase().includes(query) ||
|
||||
shot.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
// Watch filteredShots for any additional logic if needed
|
||||
|
||||
// Methods
|
||||
const loadShots = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
const options: any = {}
|
||||
|
||||
if (taskStatusFilter.value) {
|
||||
options.taskStatusFilter = taskStatusFilter.value
|
||||
}
|
||||
|
||||
const data = await shotService.getShots(options)
|
||||
shots.value = data
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load shots'
|
||||
console.error('Failed to load shots:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadEpisodes = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
const data = await episodeService.getEpisodes(props.projectId)
|
||||
episodes.value = data
|
||||
} catch (err) {
|
||||
console.error('Failed to load episodes:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTaskTypes = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
const data = await customTaskTypeService.getAllTaskTypes(props.projectId)
|
||||
allTaskTypes.value = data.shot_task_types || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load task types:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTaskStatuses = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (err) {
|
||||
console.error('Failed to load task statuses:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProjectContext = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
const project = await projectService.getProject(props.projectId)
|
||||
projectContext.value = project
|
||||
} catch (err) {
|
||||
console.error('Failed to load project context:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatValidationError = (err: ProjectValidationError, context: 'single' | 'bulk' = 'single'): ValidationErrorDisplay => {
|
||||
switch (err.code) {
|
||||
case 'PROJECT_SCOPED_NAME_CONFLICT':
|
||||
return {
|
||||
title: 'Duplicate Shot Name',
|
||||
message: context === 'bulk'
|
||||
? `One or more shots with the prefix "${err.shotName}" already exist in this project.`
|
||||
: `A shot named "${err.shotName}" already exists in this project.`,
|
||||
suggestion: context === 'bulk'
|
||||
? 'Try using a different name prefix or check existing shots.'
|
||||
: 'Please choose a different name for this shot.',
|
||||
field: context === 'bulk' ? 'name_prefix' : 'name'
|
||||
}
|
||||
case 'PROJECT_EPISODE_MISMATCH':
|
||||
return {
|
||||
title: 'Project Mismatch',
|
||||
message: 'The selected episode does not belong to the current project.',
|
||||
suggestion: 'Please select an episode from the current project.'
|
||||
}
|
||||
case 'INVALID_PROJECT_ID':
|
||||
return {
|
||||
title: 'Invalid Project',
|
||||
message: 'The specified project is not valid or accessible.',
|
||||
suggestion: 'Please refresh the page and try again.'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: 'Validation Error',
|
||||
message: err.message || 'An unknown validation error occurred.',
|
||||
suggestion: 'Please check your input and try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearValidationError = () => {
|
||||
validationError.value = null
|
||||
}
|
||||
|
||||
const handleEpisodeFilterChange = (episodeId: number | null) => {
|
||||
episodeFilter.value = episodeId
|
||||
}
|
||||
|
||||
const handleTaskStatusFilter = (filter: string) => {
|
||||
taskStatusFilter.value = filter
|
||||
loadShots()
|
||||
}
|
||||
|
||||
// Debounced column visibility handler to prevent rapid re-renders
|
||||
let visibilityUpdateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const handleColumnVisibilityChange = (visibility: VisibilityState) => {
|
||||
columnVisibility.value = visibility
|
||||
|
||||
// Debounce session storage updates
|
||||
if (visibilityUpdateTimeout) clearTimeout(visibilityUpdateTimeout)
|
||||
visibilityUpdateTimeout = setTimeout(() => {
|
||||
sessionStorage.setItem('shotBrowser.columnVisibility', JSON.stringify(visibility))
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleRowSelectionChange = (selection: Record<string, boolean>) => {
|
||||
// Update local selection state
|
||||
rowSelection.value = selection
|
||||
}
|
||||
|
||||
const getSelectedCount = () => {
|
||||
return Object.keys(rowSelection.value).length
|
||||
}
|
||||
|
||||
|
||||
|
||||
const viewTasks = (shot: Shot) => {
|
||||
selectShot(shot)
|
||||
// Always show panel when explicitly viewing tasks, regardless of enabled state
|
||||
// Show mobile detail sheet on small screens
|
||||
if (window.innerWidth < 1024) {
|
||||
showMobileDetail.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskStatusUpdated = (shotId: number, taskType: string, newStatus: TaskStatus) => {
|
||||
// Update local state instead of reloading all shots
|
||||
const shot = shots.value.find(s => s.id === shotId)
|
||||
if (shot) {
|
||||
if (!shot.task_status) {
|
||||
shot.task_status = {}
|
||||
}
|
||||
shot.task_status[taskType] = newStatus
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: 'Task status updated',
|
||||
description: `${taskType} status updated successfully`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleTaskAssignmentUpdated = (shotId: number, taskType: string, userId: number | null) => {
|
||||
// Update local state instead of reloading all shots
|
||||
const shot = shots.value.find(s => s.id === shotId)
|
||||
if (shot && shot.task_details) {
|
||||
const taskDetail = shot.task_details.find(detail => detail.task_type === taskType)
|
||||
if (taskDetail) {
|
||||
taskDetail.assigned_user_id = userId
|
||||
}
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: 'Task assignment updated',
|
||||
description: userId ? `${taskType} task assigned successfully` : `${taskType} task unassigned successfully`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkTaskStatusChange = async (taskType: string, newStatus: TaskStatus) => {
|
||||
const selectedShotIds = Object.keys(rowSelection.value).map(id => parseInt(id))
|
||||
|
||||
if (selectedShotIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Update each selected shot's task status
|
||||
const updatePromises = selectedShotIds.map(async (shotId) => {
|
||||
const shot = shots.value.find(s => s.id === shotId)
|
||||
if (shot) {
|
||||
// Update local state optimistically
|
||||
if (!shot.task_status) {
|
||||
shot.task_status = {}
|
||||
}
|
||||
shot.task_status[taskType] = newStatus
|
||||
|
||||
// Call the individual update handler to trigger any API calls
|
||||
handleTaskStatusUpdated(shotId, taskType, newStatus)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(updatePromises)
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: 'Bulk status update completed',
|
||||
description: `Updated ${taskType} status for ${selectedShotIds.length} shot${selectedShotIds.length === 1 ? '' : 's'}`,
|
||||
})
|
||||
|
||||
// Keep selection active so users can perform additional bulk operations
|
||||
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Bulk update failed',
|
||||
description: err instanceof Error ? err.message : 'An error occurred during bulk update',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const editShot = (shot: Shot) => {
|
||||
selectedShot.value = shot
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
const deleteShot = async (shot: Shot) => {
|
||||
// Don't set selectedShot here as it opens the detail panel
|
||||
// Instead, store the shot reference separately
|
||||
shotToDelete.value = shot
|
||||
|
||||
try {
|
||||
// Get deletion info first
|
||||
deletionInfo.value = await shotService.getShotDeletionInfo(shot.id)
|
||||
showDeleteDialog.value = true
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to get shot information',
|
||||
description: err instanceof Error ? err.message : 'An error occurred',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const handleCreateShot = async (shotData: ShotCreate | ShotUpdate) => {
|
||||
// Use the current episode filter or default to the first episode
|
||||
const targetEpisodeId = episodeFilter.value || episodes.value[0]?.id
|
||||
if (!targetEpisodeId) {
|
||||
toast({
|
||||
title: 'No episode selected',
|
||||
description: 'Please select an episode to create shots',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isCreating.value = true
|
||||
validationError.value = null
|
||||
const newShot = await shotService.createShot(targetEpisodeId, shotData as ShotCreate)
|
||||
shots.value.push(newShot)
|
||||
showCreateDialog.value = false
|
||||
toast({
|
||||
title: 'Shot created',
|
||||
description: `${newShot.name} has been created successfully.`
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const validationErr = err as ProjectValidationError
|
||||
validationError.value = formatValidationError(validationErr)
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to create shot',
|
||||
description: err instanceof Error ? err.message : 'An error occurred',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkCreateShots = async (bulkData: BulkShotCreate) => {
|
||||
// Use the current episode filter or default to the first episode
|
||||
const targetEpisodeId = episodeFilter.value || episodes.value[0]?.id
|
||||
if (!targetEpisodeId) {
|
||||
toast({
|
||||
title: 'No episode selected',
|
||||
description: 'Please select an episode to create shots',
|
||||
variant: 'destructive'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isBulkCreating.value = true
|
||||
validationError.value = null
|
||||
const result = await shotService.bulkCreateShots(targetEpisodeId, bulkData)
|
||||
shots.value.push(...result.created_shots)
|
||||
showBulkCreateDialog.value = false
|
||||
toast({
|
||||
title: 'Shots created',
|
||||
description: result.message
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const validationErr = err as ProjectValidationError
|
||||
validationError.value = formatValidationError(validationErr, 'bulk')
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to create shots',
|
||||
description: err instanceof Error ? err.message : 'An error occurred',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isBulkCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateShot = async (shotData: ShotCreate | ShotUpdate) => {
|
||||
if (!selectedShot.value) return
|
||||
|
||||
try {
|
||||
isUpdating.value = true
|
||||
validationError.value = null
|
||||
const updatedShot = await shotService.updateShot(selectedShot.value.id, shotData as ShotUpdate)
|
||||
|
||||
// Update in shots list
|
||||
const index = shots.value.findIndex(shot => shot.id === selectedShot.value!.id)
|
||||
if (index !== -1) {
|
||||
shots.value[index] = updatedShot
|
||||
}
|
||||
|
||||
showEditDialog.value = false
|
||||
selectedShot.value = null
|
||||
toast({
|
||||
title: 'Shot updated',
|
||||
description: 'Shot has been updated successfully.'
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const validationErr = err as ProjectValidationError
|
||||
validationError.value = formatValidationError(validationErr)
|
||||
} else {
|
||||
toast({
|
||||
title: 'Failed to update shot',
|
||||
description: err instanceof Error ? err.message : 'An error occurred',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteShot = async () => {
|
||||
if (!shotToDelete.value) return
|
||||
|
||||
try {
|
||||
// Delete shot (soft deletion by default)
|
||||
await shotService.deleteShot(shotToDelete.value.id)
|
||||
|
||||
// Remove from shots list
|
||||
const index = shots.value.findIndex(shot => shot.id === shotToDelete.value!.id)
|
||||
if (index !== -1) {
|
||||
shots.value.splice(index, 1)
|
||||
}
|
||||
|
||||
showDeleteDialog.value = false
|
||||
showMobileDetail.value = false
|
||||
shotToDelete.value = null
|
||||
|
||||
const taskCount = deletionInfo.value?.task_count || 0
|
||||
deletionInfo.value = null
|
||||
toast({
|
||||
title: 'Shot deleted',
|
||||
description: taskCount > 0
|
||||
? `Shot and ${taskCount} associated task${taskCount === 1 ? '' : 's'} deleted successfully.`
|
||||
: 'Shot deleted successfully.'
|
||||
})
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to delete shot',
|
||||
description: err instanceof Error ? err.message : 'An error occurred',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
// This will be handled by the parent component
|
||||
// For now, just reload shots to clear any filters
|
||||
loadShots()
|
||||
}
|
||||
|
||||
const handleCreateTask = () => {
|
||||
// TODO: Navigate to task creation for this shot
|
||||
console.log('Create task for shot:', selectedShot.value?.name)
|
||||
}
|
||||
|
||||
const handleSelectTask = (task: any) => {
|
||||
// TODO: Navigate to task detail view
|
||||
console.log('View task:', task.name)
|
||||
}
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return 'secondary'
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return 'default'
|
||||
case ShotStatus.ON_HOLD:
|
||||
return 'outline'
|
||||
case ShotStatus.COMPLETED:
|
||||
return 'default'
|
||||
case ShotStatus.APPROVED:
|
||||
return 'default'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
// Memoized shot columns - use shallowRef to avoid deep reactivity on column objects
|
||||
const shotColumns = shallowRef<ReturnType<typeof createShotColumns>>([])
|
||||
|
||||
// Create a stable reference to the meta object to prevent unnecessary column recreation
|
||||
const createStableMeta = (): ShotColumnMeta => ({
|
||||
projectId: props.projectId,
|
||||
episodes: episodes.value,
|
||||
onEdit: editShot,
|
||||
onDelete: deleteShot,
|
||||
onViewTasks: viewTasks,
|
||||
onTaskStatusUpdated: handleTaskStatusUpdated,
|
||||
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
|
||||
onBulkTaskStatusChange: handleBulkTaskStatusChange,
|
||||
getSelectedCount: getSelectedCount,
|
||||
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId),
|
||||
})
|
||||
|
||||
// Update columns only when dependencies actually change
|
||||
const updateColumns = () => {
|
||||
const meta = createStableMeta()
|
||||
// Use markRaw to prevent deep reactivity on column definitions
|
||||
shotColumns.value = markRaw(createShotColumns(allTaskTypes.value, meta))
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.selectedEpisodeId, (newEpisodeId) => {
|
||||
// Set the episode filter based on the selected episode from parent
|
||||
episodeFilter.value = newEpisodeId ?? null
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.projectId, (newProjectId) => {
|
||||
if (newProjectId) {
|
||||
loadProjectContext()
|
||||
loadEpisodes()
|
||||
loadTaskTypes()
|
||||
loadTaskStatuses()
|
||||
loadShots()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Clear validation errors when dialogs are closed
|
||||
watch(() => showCreateDialog.value, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationError.value = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => showBulkCreateDialog.value, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationError.value = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => showEditDialog.value, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationError.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for changes that require column recreation (after all functions are defined)
|
||||
watch([() => allTaskTypes.value, () => episodes.value, () => props.projectId], updateColumns, { immediate: true })
|
||||
</script>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<Card
|
||||
class="group hover:shadow-md transition-all duration-200 cursor-pointer border-border/50 hover:border-border"
|
||||
@click="$emit('select', shot)"
|
||||
>
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex-shrink-0">
|
||||
<Camera class="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base truncate">{{ shot.name }}</CardTitle>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Frames {{ shot.frame_start }}-{{ shot.frame_end }}
|
||||
<span class="ml-2">({{ frameCount }} frames)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild @click.stop>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
<span class="sr-only">Shot actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click.stop="$emit('edit', shot)">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit Shot
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click.stop="$emit('select', shot)">
|
||||
<ListTodo class="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click.stop="$emit('delete', shot)"
|
||||
class="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete Shot
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="pt-0">
|
||||
<!-- Status Badge -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<Badge :variant="getStatusVariant(shot.status)" class="text-xs">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full mr-1"
|
||||
:class="getStatusColor(shot.status)"
|
||||
></div>
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
|
||||
<!-- Task Count -->
|
||||
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<ListTodo class="h-3 w-3" />
|
||||
<span>{{ shot.task_count }} tasks</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="shot.description"
|
||||
class="text-sm text-muted-foreground line-clamp-2 mb-3"
|
||||
>
|
||||
{{ shot.description }}
|
||||
</p>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Created {{ formatDate(shot.created_at) }}</span>
|
||||
<span v-if="shot.updated_at !== shot.created_at">
|
||||
Updated {{ formatDate(shot.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Camera, MoreHorizontal, Edit, ListTodo, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ShotStatus, type Shot } from '@/services/shot'
|
||||
|
||||
interface Props {
|
||||
shot: Shot
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', shot: Shot): void
|
||||
(e: 'edit', shot: Shot): void
|
||||
(e: 'delete', shot: Shot): void
|
||||
(e: 'view-tasks', shot: Shot): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
// Computed properties
|
||||
const frameCount = computed(() => {
|
||||
return props.shot.frame_end - props.shot.frame_start + 1
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return 'secondary'
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return 'default'
|
||||
case ShotStatus.ON_HOLD:
|
||||
return 'outline'
|
||||
case ShotStatus.COMPLETED:
|
||||
return 'default'
|
||||
case ShotStatus.APPROVED:
|
||||
return 'default'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return 'bg-gray-400'
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return 'bg-blue-500'
|
||||
case ShotStatus.ON_HOLD:
|
||||
return 'bg-yellow-500'
|
||||
case ShotStatus.COMPLETED:
|
||||
return 'bg-green-500'
|
||||
case ShotStatus.APPROVED:
|
||||
return 'bg-emerald-600'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="selectedColumn" @update:model-value="handleColumnToggle">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Toggle columns">
|
||||
<div class="flex items-center gap-2">
|
||||
<Columns class="h-4 w-4" />
|
||||
<span>Columns</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="toggle">Toggle Columns</SelectItem>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Basic Columns</SelectLabel>
|
||||
<SelectItem value="thumbnail" @click="toggleColumn('thumbnail')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('thumbnail')"
|
||||
@change="handleCheckboxChange('thumbnail', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Thumbnail</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="name" @click="toggleColumn('name')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('name')"
|
||||
@change="handleCheckboxChange('name', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Shot Name</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="episode" @click="toggleColumn('episode')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('episode')"
|
||||
@change="handleCheckboxChange('episode', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Episode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="frameRange" @click="toggleColumn('frameRange')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('frameRange')"
|
||||
@change="handleCheckboxChange('frameRange', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Frame Range</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="status" @click="toggleColumn('status')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('status')"
|
||||
@change="handleCheckboxChange('status', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Task Status Columns</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="taskType in allTaskTypes"
|
||||
:key="taskType"
|
||||
:value="taskType"
|
||||
@click="toggleColumn(taskType)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible(taskType)"
|
||||
@change="handleCheckboxChange(taskType, $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>{{ formatTaskType(taskType) }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Other Columns</SelectLabel>
|
||||
<SelectItem value="description" @click="toggleColumn('description')">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isColumnVisible('description')"
|
||||
@change="handleCheckboxChange('description', $event)"
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span>Description</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Columns } from 'lucide-vue-next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
|
||||
interface Props {
|
||||
columnVisibility: VisibilityState
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
}>()
|
||||
|
||||
const selectedColumn = ref('toggle')
|
||||
|
||||
const handleColumnToggle = (value: string) => {
|
||||
// Reset selection after interaction
|
||||
selectedColumn.value = 'toggle'
|
||||
}
|
||||
|
||||
const isColumnVisible = (columnId: string) => {
|
||||
// If not in visibility state, column is visible by default
|
||||
return props.columnVisibility[columnId] !== false
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (column: string, event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
updateColumn(column, target.checked)
|
||||
}
|
||||
|
||||
const toggleColumn = (column: string) => {
|
||||
updateColumn(column, !isColumnVisible(column))
|
||||
}
|
||||
|
||||
const updateColumn = (column: string, checked: boolean) => {
|
||||
const newVisibility = { ...props.columnVisibility }
|
||||
newVisibility[column] = checked
|
||||
emit('update:columnVisibility', newVisibility)
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<AlertTriangle class="h-5 w-5 text-destructive" />
|
||||
Soft Delete Shot: {{ shotName }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will mark the shot and all related data as deleted while preserving it for potential recovery.
|
||||
The data will be hidden from normal operations but can be restored by administrators.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingInfo" class="flex items-center justify-center py-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span class="text-sm text-muted-foreground">Loading deletion information...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<Alert v-else-if="loadError" variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<AlertTitle>Failed to load deletion information</AlertTitle>
|
||||
<AlertDescription>{{ loadError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Deletion Information -->
|
||||
<div v-else-if="deletionInfo" class="space-y-4">
|
||||
<!-- Impact Summary -->
|
||||
<div class="rounded-lg border bg-muted/20 p-4">
|
||||
<h3 class="font-medium mb-3">Deletion Impact Summary</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<ListTodo class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.task_count }} task{{ deletionInfo.task_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Upload class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.submission_count }} submission{{ deletionInfo.submission_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Paperclip class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.attachment_count }} attachment{{ deletionInfo.attachment_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<MessageSquare class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.note_count }} note{{ deletionInfo.note_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ deletionInfo.review_count }} review{{ deletionInfo.review_count === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<HardDrive class="h-4 w-4 text-muted-foreground" />
|
||||
<span>{{ formatFileSize(deletionInfo.total_file_size) }} files</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Affected Users -->
|
||||
<Alert v-if="deletionInfo.affected_users.length > 0" variant="default" class="border-orange-200 bg-orange-50">
|
||||
<Users class="h-4 w-4 text-orange-600" />
|
||||
<AlertTitle class="text-orange-800">
|
||||
{{ deletionInfo.affected_users.length }} user{{ deletionInfo.affected_users.length === 1 ? '' : 's' }} will be affected
|
||||
</AlertTitle>
|
||||
<AlertDescription class="text-orange-700">
|
||||
<p class="mb-3">
|
||||
The following users have work associated with this shot that will be marked as deleted:
|
||||
</p>
|
||||
|
||||
<div class="max-h-40 overflow-y-auto space-y-2">
|
||||
<div
|
||||
v-for="user in deletionInfo.affected_users"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between p-2 bg-white rounded border"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ user.name }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ user.email }} • {{ user.role }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground text-right">
|
||||
<div v-if="user.task_count > 0">{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.submission_count > 0">{{ user.submission_count }} submission{{ user.submission_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.note_count > 0">{{ user.note_count }} note{{ user.note_count === 1 ? '' : 's' }}</div>
|
||||
<div v-if="user.last_activity_date" class="mt-1">
|
||||
Last active: {{ formatDate(user.last_activity_date) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- No affected users -->
|
||||
<Alert v-else variant="default" class="border-green-200 bg-green-50">
|
||||
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||
<AlertDescription class="text-green-800">
|
||||
No users will be affected by this deletion.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Data Preservation Notice -->
|
||||
<Alert variant="default" class="border-blue-200 bg-blue-50">
|
||||
<Shield class="h-4 w-4 text-blue-600" />
|
||||
<AlertTitle class="text-blue-800">Data Preservation</AlertTitle>
|
||||
<AlertDescription class="text-blue-700">
|
||||
All data will be preserved in the database and can be recovered by administrators.
|
||||
Files will remain on the server unchanged. This is a soft deletion, not permanent removal.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Confirmation input -->
|
||||
<div class="space-y-2">
|
||||
<Label for="confirm-input">
|
||||
Type <code class="bg-muted px-1 py-0.5 rounded text-sm">{{ shotName }}</code> to confirm soft deletion:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-input"
|
||||
v-model="confirmationText"
|
||||
placeholder="Enter shot name to confirm"
|
||||
class="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="$emit('update:open', false)">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="!isConfirmed || isDeleting || isLoadingInfo || !!loadError"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<Loader2 v-if="isDeleting" class="mr-2 h-4 w-4 animate-spin" />
|
||||
Soft Delete Shot
|
||||
<span v-if="deletionInfo && deletionInfo.task_count > 0">
|
||||
and {{ deletionInfo.task_count }} Task{{ deletionInfo.task_count === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
ListTodo,
|
||||
Upload,
|
||||
Paperclip,
|
||||
MessageSquare,
|
||||
HardDrive,
|
||||
Users,
|
||||
Shield
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/ui/alert'
|
||||
import { shotService, type ShotDeletionInfo } from '@/services/shot'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
shotId: number
|
||||
shotName: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'confirm-delete': []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const confirmationText = ref('')
|
||||
const isDeleting = ref(false)
|
||||
const isLoadingInfo = ref(false)
|
||||
const loadError = ref<string | null>(null)
|
||||
const deletionInfo = ref<ShotDeletionInfo | null>(null)
|
||||
|
||||
const isConfirmed = computed(() => {
|
||||
return confirmationText.value === props.shotName
|
||||
})
|
||||
|
||||
// Load deletion info when dialog opens
|
||||
const loadDeletionInfo = async () => {
|
||||
if (!props.shotId) return
|
||||
|
||||
isLoadingInfo.value = true
|
||||
loadError.value = null
|
||||
|
||||
try {
|
||||
deletionInfo.value = await shotService.getShotDeletionInfo(props.shotId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load deletion info:', error)
|
||||
loadError.value = error instanceof Error ? error.message : 'Failed to load deletion information'
|
||||
} finally {
|
||||
isLoadingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size for display
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
confirmationText.value = ''
|
||||
isDeleting.value = false
|
||||
deletionInfo.value = null
|
||||
loadError.value = null
|
||||
loadDeletionInfo()
|
||||
} else {
|
||||
confirmationText.value = ''
|
||||
isDeleting.value = false
|
||||
deletionInfo.value = null
|
||||
loadError.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isConfirmed.value) return
|
||||
|
||||
isDeleting.value = true
|
||||
try {
|
||||
emit('confirm-delete')
|
||||
} catch (error) {
|
||||
console.error('Delete operation failed:', error)
|
||||
toast({
|
||||
title: 'Deletion failed',
|
||||
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,520 @@
|
||||
<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 shot 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 shot</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="loadShotDetails" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Shot Details -->
|
||||
<div v-else-if="shot" 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': shot.deleted_at }">{{ shot.name }}</h2>
|
||||
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full mr-1"
|
||||
:class="getStatusColor(shot.status)"
|
||||
></div>
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
<!-- Deletion status indicator for admins -->
|
||||
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||
Deleted {{ formatDeletedDate(shot.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-5 rounded-none border-b">
|
||||
<TabsTrigger value="infos">Infos</TabsTrigger>
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
<TabsTrigger value="assets">Assets</TabsTrigger>
|
||||
<TabsTrigger value="references">References</TabsTrigger>
|
||||
<TabsTrigger value="design">Design</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Infos Tab -->
|
||||
<TabsContent value="infos" class="flex-1 p-6 space-y-6">
|
||||
<!-- Shot Information -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Shot Information</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Frame Range</Label>
|
||||
<p class="text-sm mt-1">{{ shot.frame_start }} - {{ shot.frame_end }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Duration</Label>
|
||||
<p class="text-sm mt-1">{{ frameCount }} frames</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Description</Label>
|
||||
<p class="text-sm mt-1">
|
||||
{{ shot.description || 'No description provided' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Created</Label>
|
||||
<p>{{ formatDate(shot.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Updated</Label>
|
||||
<p>{{ formatDate(shot.updated_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Overview -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">Progress Overview</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>
|
||||
<!-- Task Status Summary -->
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-gray-400"></div>
|
||||
<span class="text-muted-foreground">Not Started: {{ taskStatusCounts.not_started }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||
<span class="text-muted-foreground">In Progress: {{ taskStatusCounts.in_progress }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-yellow-500"></div>
|
||||
<span class="text-muted-foreground">Submitted: {{ taskStatusCounts.submitted }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span class="text-muted-foreground">Approved: {{ taskStatusCounts.approved }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks List -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">Tasks</h3>
|
||||
<Button
|
||||
v-if="canCreateTask"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('create-task')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
<!-- No Tasks -->
|
||||
<div v-if="tasks.length === 0" class="text-center py-8">
|
||||
<ListTodo class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">No tasks yet</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">Create tasks to track work on this shot</p>
|
||||
</div>
|
||||
<!-- Tasks Table -->
|
||||
<div v-else class="border rounded-lg overflow-hidden">
|
||||
<div class="bg-muted/50 px-4 py-2 grid grid-cols-3 gap-4 text-xs font-medium text-muted-foreground border-b">
|
||||
<div>Task Type</div>
|
||||
<div>Assignee</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="px-4 py-3 grid grid-cols-3 gap-4 items-center hover:bg-muted/50 cursor-pointer transition-colors border-b last:border-b-0"
|
||||
@click="$emit('select-task', task)"
|
||||
>
|
||||
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ task.assigned_user_name || 'Unassigned' }}
|
||||
</div>
|
||||
<div>
|
||||
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs">
|
||||
{{ formatTaskStatus(task.status.toString()) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Notes Tab -->
|
||||
<TabsContent value="notes" class="flex-1 p-6 space-y-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Production Notes</h3>
|
||||
<Button
|
||||
v-if="canCreateNote"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('create-note')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">No notes yet</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">Add notes to track important information</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Assets Tab -->
|
||||
<TabsContent value="assets" class="flex-1 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Linked Assets</h3>
|
||||
<Button
|
||||
v-if="canLinkAssets"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('link-asset')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Link Asset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
<Package class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">No assets linked</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">Link assets that are used in this shot</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- References Tab -->
|
||||
<TabsContent value="references" class="flex-1 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Reference Files</h3>
|
||||
<Button
|
||||
v-if="canUploadReferences"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('upload-reference')"
|
||||
>
|
||||
<Plus class="h-3 w-3 mr-1" />
|
||||
Upload Reference
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-8">
|
||||
<Image class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">No reference files</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">Upload images, videos, or documents for reference</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Design Tab -->
|
||||
<TabsContent value="design" class="flex-1 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Design Information</h3>
|
||||
<Button
|
||||
v-if="canEditDesign"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('edit-design')"
|
||||
>
|
||||
<Edit class="h-3 w-3 mr-1" />
|
||||
Edit Design
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Camera Notes</Label>
|
||||
<p class="text-sm mt-1 text-muted-foreground">No camera notes</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Lighting Notes</Label>
|
||||
<p class="text-sm mt-1 text-muted-foreground">No lighting notes</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Animation Notes</Label>
|
||||
<p class="text-sm mt-1 text-muted-foreground">No animation notes</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit
|
||||
} 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 { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||
interface Task extends TaskStatusInfo {
|
||||
id: number
|
||||
name?: string
|
||||
assigned_user_name?: string
|
||||
deadline?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
shotId: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'edit', shot: Shot): void
|
||||
(e: 'delete', shot: Shot): void
|
||||
(e: 'create-task'): void
|
||||
(e: 'select-task', task: Task): void
|
||||
(e: 'create-note'): void
|
||||
(e: 'link-asset'): void
|
||||
(e: 'upload-reference'): void
|
||||
(e: 'edit-design'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Reactive state
|
||||
const shot = ref<Shot | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const frameCount = computed(() => {
|
||||
if (!shot.value) return 0
|
||||
return shot.value.frame_end - shot.value.frame_start + 1
|
||||
})
|
||||
|
||||
const completedTasksCount = computed(() => {
|
||||
return tasks.value.filter(task => task.status.toString() === '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 => {
|
||||
const statusKey = task.status.toString() // Convert enum to string
|
||||
if (counts.hasOwnProperty(statusKey)) {
|
||||
counts[statusKey as keyof typeof counts]++
|
||||
}
|
||||
})
|
||||
|
||||
return counts
|
||||
})
|
||||
|
||||
|
||||
|
||||
const canCreateTask = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
const canCreateNote = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
const canLinkAssets = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
const canUploadReferences = computed(() => {
|
||||
return true // All users can upload references
|
||||
})
|
||||
|
||||
const canEditDesign = computed(() => {
|
||||
return authStore.user?.role === 'coordinator' || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadShotDetails = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
shot.value = await shotService.getShot(props.shotId)
|
||||
loadTasks() // No longer async - uses embedded data
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
|
||||
console.error('Failed to load shot details:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTasks = () => {
|
||||
// Use task_details already embedded in shot data - no API call needed!
|
||||
if (shot.value?.task_details) {
|
||||
tasks.value = shot.value.task_details.map(taskInfo => ({
|
||||
id: taskInfo.task_id || 0,
|
||||
task_type: taskInfo.task_type,
|
||||
status: taskInfo.status,
|
||||
assigned_user_id: taskInfo.assigned_user_id,
|
||||
// Add placeholder values for display compatibility
|
||||
name: taskInfo.task_type, // Use task_type as name for display
|
||||
assigned_user_name: undefined // Will be resolved if needed
|
||||
}))
|
||||
} else {
|
||||
tasks.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
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: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return 'secondary'
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return 'default'
|
||||
case ShotStatus.ON_HOLD:
|
||||
return 'outline'
|
||||
case ShotStatus.COMPLETED:
|
||||
return 'default'
|
||||
case ShotStatus.APPROVED:
|
||||
return 'default'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return 'bg-gray-400'
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return 'bg-blue-500'
|
||||
case ShotStatus.ON_HOLD:
|
||||
return 'bg-yellow-500'
|
||||
case ShotStatus.COMPLETED:
|
||||
return 'bg-green-500'
|
||||
case ShotStatus.APPROVED:
|
||||
return 'bg-emerald-600'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const getTaskStatusVariant = (status: string | TaskStatus) => {
|
||||
const statusStr = status.toString()
|
||||
switch (statusStr) {
|
||||
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.shotId, (newShotId) => {
|
||||
if (newShotId) {
|
||||
loadShotDetails()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
refresh: loadShotDetails
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Project Context Display -->
|
||||
<div v-if="projectContext" class="p-3 bg-muted/50 rounded-md border">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div class="font-medium text-muted-foreground">Project:</div>
|
||||
<div class="font-semibold">{{ projectContext.name }}</div>
|
||||
<div v-if="episodeContext" class="text-muted-foreground">
|
||||
• Episode: {{ episodeContext.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="projectContext.code_name" class="text-xs text-muted-foreground mt-1">
|
||||
Code: {{ projectContext.code_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Error Display -->
|
||||
<div v-if="validationError" class="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-destructive">{{ validationError.title }}</div>
|
||||
<div class="text-sm text-destructive/80">{{ validationError.message }}</div>
|
||||
<div v-if="validationError.suggestion" class="text-xs text-muted-foreground">
|
||||
{{ validationError.suggestion }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shot Name -->
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Shot Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
placeholder="Enter shot name (e.g., SH010)"
|
||||
:disabled="isLoading"
|
||||
:class="validationError?.field === 'name' ? 'border-destructive focus-visible:ring-destructive' : ''"
|
||||
required
|
||||
/>
|
||||
<div v-if="projectContext" class="text-xs text-muted-foreground">
|
||||
Shot names must be unique within the project "{{ projectContext.name }}"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frame Range -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="frame_start">Start Frame</Label>
|
||||
<Input
|
||||
id="frame_start"
|
||||
v-model.number="formData.frame_start"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1001"
|
||||
:disabled="isLoading"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="frame_end">End Frame</Label>
|
||||
<Input
|
||||
id="frame_end"
|
||||
v-model.number="formData.frame_end"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1100"
|
||||
:disabled="isLoading"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frame Count Display -->
|
||||
<div v-if="frameCount > 0" class="text-sm text-muted-foreground">
|
||||
Total frames: {{ frameCount }}
|
||||
</div>
|
||||
|
||||
<!-- Shot Status -->
|
||||
<div class="space-y-2">
|
||||
<Label for="status">Status</Label>
|
||||
<Select v-model="formData.status" :disabled="isLoading">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="status in statuses"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="getStatusColor(status.value)"
|
||||
></div>
|
||||
{{ status.label }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
placeholder="Enter shot description (optional)"
|
||||
:disabled="isLoading"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isLoading || !isFormValid"
|
||||
>
|
||||
<div v-if="isLoading" class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{{ isEdit ? 'Updating...' : 'Creating...' }}
|
||||
</div>
|
||||
<span v-else>{{ isEdit ? 'Update Shot' : 'Create Shot' }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { AlertCircle } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ShotStatus, type Shot, type ShotCreate, type ShotUpdate, type ProjectValidationError } from '@/services/shot'
|
||||
import type { Project } from '@/services/project'
|
||||
import type { Episode } from '@/services/episode'
|
||||
|
||||
interface ValidationErrorDisplay {
|
||||
title: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
field?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
shot?: Shot
|
||||
isLoading?: boolean
|
||||
projectContext?: Project
|
||||
episodeContext?: Episode
|
||||
validationError?: ValidationErrorDisplay | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', data: ShotCreate | ShotUpdate): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Form data
|
||||
const formData = ref({
|
||||
name: '',
|
||||
frame_start: 1001,
|
||||
frame_end: 1001,
|
||||
status: ShotStatus.NOT_STARTED,
|
||||
description: ''
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const isEdit = computed(() => !!props.shot)
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
formData.value.name.trim() !== '' &&
|
||||
formData.value.frame_start > 0 &&
|
||||
formData.value.frame_end > 0 &&
|
||||
formData.value.frame_end >= formData.value.frame_start
|
||||
)
|
||||
})
|
||||
|
||||
const frameCount = computed(() => {
|
||||
if (formData.value.frame_end >= formData.value.frame_start) {
|
||||
return formData.value.frame_end - formData.value.frame_start + 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// Status options
|
||||
const statuses = [
|
||||
{ value: ShotStatus.NOT_STARTED, label: 'Not Started' },
|
||||
{ value: ShotStatus.IN_PROGRESS, label: 'In Progress' },
|
||||
{ value: ShotStatus.ON_HOLD, label: 'On Hold' },
|
||||
{ value: ShotStatus.COMPLETED, label: 'Completed' },
|
||||
{ value: ShotStatus.APPROVED, label: 'Approved' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
const getStatusColor = (status: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return 'bg-gray-400'
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return 'bg-blue-500'
|
||||
case ShotStatus.ON_HOLD:
|
||||
return 'bg-yellow-500'
|
||||
case ShotStatus.COMPLETED:
|
||||
return 'bg-green-500'
|
||||
case ShotStatus.APPROVED:
|
||||
return 'bg-emerald-600'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
// Clear any existing validation errors
|
||||
emit('clear-error')
|
||||
|
||||
const data = {
|
||||
name: formData.value.name.trim(),
|
||||
frame_start: formData.value.frame_start,
|
||||
frame_end: formData.value.frame_end,
|
||||
status: formData.value.status,
|
||||
description: formData.value.description.trim() || undefined,
|
||||
// Include project_id if available for validation consistency
|
||||
project_id: props.projectContext?.id
|
||||
}
|
||||
|
||||
emit('submit', data)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
name: '',
|
||||
frame_start: 1001,
|
||||
frame_end: 1001,
|
||||
status: ShotStatus.NOT_STARTED,
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for shot changes to populate form
|
||||
watch(
|
||||
() => props.shot,
|
||||
(shot) => {
|
||||
if (shot) {
|
||||
formData.value = {
|
||||
name: shot.name,
|
||||
frame_start: shot.frame_start,
|
||||
frame_end: shot.frame_end,
|
||||
status: shot.status,
|
||||
description: shot.description || ''
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Clear validation error when form data changes
|
||||
watch(
|
||||
() => formData.value.name,
|
||||
() => {
|
||||
if (props.validationError?.field === 'name') {
|
||||
emit('clear-error')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Expose reset method
|
||||
defineExpose({
|
||||
resetForm
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Main Toolbar Row -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- Left Side - Filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'grid' }"
|
||||
@click="$emit('update:view-mode', 'grid')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<LayoutGrid class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'list' }"
|
||||
@click="$emit('update:view-mode', 'list')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<List class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'bg-muted': viewMode === 'table' }"
|
||||
@click="$emit('update:view-mode', 'table')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<Table2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Episode Filter -->
|
||||
<Popover v-if="episodes.length > 0">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Film class="mr-2 h-4 w-4" />
|
||||
Episode
|
||||
<Badge
|
||||
v-if="episodeFilter !== null"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search episode..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No episode found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="all"
|
||||
@select="$emit('update:episode-filter', null)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
episodeFilter === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>All Episodes</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
v-for="episode in episodes"
|
||||
:key="episode.id"
|
||||
:value="episode.id.toString()"
|
||||
@select="$emit('update:episode-filter', episode.id)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
episodeFilter === episode.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ episode.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Task Status Filter (only for table view) -->
|
||||
<ShotTaskStatusFilter
|
||||
v-if="viewMode === 'table'"
|
||||
:all-task-types="allTaskTypes"
|
||||
:project-id="projectId"
|
||||
@filter-changed="$emit('task-status-filter-changed', $event)"
|
||||
/>
|
||||
|
||||
<!-- Column Visibility Control (only for table view) -->
|
||||
<Popover v-if="viewMode === 'table'">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Settings2 class="mr-2 h-4 w-4" />
|
||||
View
|
||||
<Badge
|
||||
v-if="hiddenColumnsCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ hiddenColumnsCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandSeparator />
|
||||
<template v-for="column in allColumns">
|
||||
<CommandItem
|
||||
v-if="column.type == 'default'"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</CommandGroup>
|
||||
<CommandGroup>
|
||||
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
All Task Types
|
||||
</div>
|
||||
<CommandSeparator />
|
||||
<template v-for="column in allColumns">
|
||||
<CommandItem
|
||||
v-if="column.type == 'task'"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Task Columns Toggle Button (only for table view) -->
|
||||
<Button
|
||||
v-if="viewMode === 'table'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleAllTaskColumns"
|
||||
class="h-8"
|
||||
>
|
||||
<ListTodo v-if="!allTaskColumnsVisible" class="h-4 w-4 mr-2" />
|
||||
<ListX v-else class="h-4 w-4 mr-2" />
|
||||
{{ allTaskColumnsVisible ? 'Hide' : 'Show' }} Tasks
|
||||
</Button>
|
||||
|
||||
<!-- Detail Panel Enable/Disable Toggle Button (only for table view) -->
|
||||
<Button
|
||||
v-if="viewMode === 'table'"
|
||||
@click="$emit('toggle-detail-panel')"
|
||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
:class="[
|
||||
'h-8 w-8 p-0',
|
||||
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
|
||||
]"
|
||||
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
|
||||
>
|
||||
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<Button
|
||||
v-if="hasFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Right Side - Search and Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<!-- Search -->
|
||||
<div class="relative w-64">
|
||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
:model-value="search"
|
||||
@update:model-value="debouncedSearch"
|
||||
placeholder="Search shots..."
|
||||
class="pl-9 h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<Button @click="$emit('bulk-create')" variant="outline" size="sm" class="h-8 w-8 p-0">
|
||||
<Layers class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button @click="$emit('create-shot')" size="sm" class="h-8 w-8 p-0">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
|
||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import ShotTaskStatusFilter from './ShotTaskStatusFilter.vue'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import type { Episode } from '@/services/episode'
|
||||
import type { Shot } from '@/services/shot'
|
||||
|
||||
interface Props {
|
||||
viewMode: 'grid' | 'list' | 'table'
|
||||
episodeFilter: number | null
|
||||
search: string
|
||||
columnVisibility: VisibilityState
|
||||
episodes: Episode[]
|
||||
allTaskTypes: string[]
|
||||
projectId: number
|
||||
selectedShot: Shot | null
|
||||
isDetailPanelEnabled: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:view-mode': [value: 'grid' | 'list' | 'table']
|
||||
'update:episode-filter': [value: number | null]
|
||||
'update:search': [value: string]
|
||||
'update:column-visibility': [value: VisibilityState]
|
||||
'task-status-filter-changed': [value: string]
|
||||
'toggle-detail-panel': []
|
||||
'toggle-task-columns': []
|
||||
'bulk-create': []
|
||||
'create-shot': []
|
||||
}>()
|
||||
|
||||
// Column definitions - computed to be reactive to allTaskTypes changes
|
||||
const allColumns = computed(() => [
|
||||
{ id: 'thumbnail', label: 'Thumbnail', type: 'default' },
|
||||
{ id: 'name', label: 'Shot Name', type: 'default' },
|
||||
{ id: 'episode', label: 'Episode', type: 'default' },
|
||||
{ id: 'frameRange', label: 'Frame Range', type: 'default' },
|
||||
{ id: 'frames', label: 'Frames', type: 'default' },
|
||||
{ id: 'status', label: 'Status', type: 'default' },
|
||||
{ id: 'description', label: 'Description', type: 'default' },
|
||||
...props.allTaskTypes.map(taskType => ({
|
||||
id: taskType,
|
||||
label: taskType.charAt(0).toUpperCase() + taskType.slice(1),
|
||||
type: 'task'
|
||||
})),
|
||||
|
||||
])
|
||||
|
||||
// Computed
|
||||
const hasFilters = computed(() => {
|
||||
return (
|
||||
props.episodeFilter !== null ||
|
||||
props.search !== ''
|
||||
)
|
||||
})
|
||||
|
||||
const hiddenColumnsCount = computed(() => {
|
||||
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
|
||||
})
|
||||
|
||||
// Check if all task columns are visible
|
||||
const allTaskColumnsVisible = computed(() => {
|
||||
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
||||
return taskColumns.length > 0 && taskColumns.every(col => props.columnVisibility[col.id] !== false)
|
||||
})
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const debouncedSearch = (value: string | number) => {
|
||||
const searchValue = typeof value === 'string' ? value : String(value)
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
emit('update:search', searchValue)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Methods
|
||||
const toggleColumn = (columnId: string, value: any) => {
|
||||
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
|
||||
emit('update:column-visibility', newVisibility)
|
||||
}
|
||||
|
||||
const toggleAllTaskColumns = () => {
|
||||
const newVisibility = { ...props.columnVisibility }
|
||||
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
||||
|
||||
// If all task columns are visible, hide them; otherwise show them
|
||||
const shouldHide = allTaskColumnsVisible.value
|
||||
|
||||
taskColumns.forEach(col => {
|
||||
newVisibility[col.id] = !shouldHide
|
||||
})
|
||||
|
||||
emit('update:column-visibility', newVisibility)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
emit('update:episode-filter', null)
|
||||
emit('update:search', '')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<ListFilter class="mr-2 h-4 w-4" />
|
||||
Task Status
|
||||
<Badge
|
||||
v-if="selectedFilters.length > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ selectedFilters.length }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[220px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search task status..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No status found.</CommandEmpty>
|
||||
|
||||
<!-- All Tasks Option -->
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="all"
|
||||
@select="clearAllFilters"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
selectedFilters.length === 0
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>All Tasks</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<!-- Task Type Groups -->
|
||||
<CommandGroup
|
||||
v-for="taskType in allTaskTypes"
|
||||
:key="taskType"
|
||||
>
|
||||
<CommandSeparator />
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{{ formatTaskType(taskType) }}
|
||||
</div>
|
||||
<CommandItem
|
||||
v-for="status in allStatuses"
|
||||
:key="`${taskType}:${status.id}`"
|
||||
:value="`${taskType}:${status.id}`"
|
||||
@select="toggleFilter(`${taskType}:${status.id}`)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
selectedFilters.includes(`${taskType}:${status.id}`)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<TaskStatusBadge :status="status as any" />
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ListFilter, Check } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/shot'
|
||||
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
|
||||
interface Props {
|
||||
allTaskTypes: string[]
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'filter-changed': [filter: string]
|
||||
}>()
|
||||
|
||||
// Use the shared task statuses store
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
const selectedFilters = ref<string[]>([])
|
||||
|
||||
// Get loading state from store
|
||||
const isLoading = computed(() => {
|
||||
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
|
||||
})
|
||||
|
||||
// System status options (fallback if no project ID)
|
||||
const defaultStatusOptions = [
|
||||
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
|
||||
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
|
||||
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
|
||||
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
|
||||
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
|
||||
]
|
||||
|
||||
// Combine system and custom statuses
|
||||
const allStatuses = computed(() => {
|
||||
if (!props.projectId) {
|
||||
return defaultStatusOptions
|
||||
}
|
||||
|
||||
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
|
||||
if (!statusData) {
|
||||
return defaultStatusOptions
|
||||
}
|
||||
|
||||
// Convert system statuses to the format expected by TaskStatusBadge
|
||||
const systemStatusList = statusData.system_statuses.map(status => ({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
is_system: status.is_system
|
||||
}))
|
||||
|
||||
// Convert custom statuses to the format expected by TaskStatusBadge
|
||||
const customStatusList = statusData.statuses.map(status => ({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
is_system: false
|
||||
}))
|
||||
|
||||
return [...systemStatusList, ...customStatusList]
|
||||
})
|
||||
|
||||
// Load custom statuses when component mounts or projectId changes
|
||||
const loadStatuses = async () => {
|
||||
if (!props.projectId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
watch(() => props.projectId, () => {
|
||||
loadStatuses()
|
||||
})
|
||||
|
||||
const toggleFilter = (filter: string) => {
|
||||
const index = selectedFilters.value.indexOf(filter)
|
||||
if (index > -1) {
|
||||
selectedFilters.value.splice(index, 1)
|
||||
} else {
|
||||
selectedFilters.value.push(filter)
|
||||
}
|
||||
|
||||
// Emit the filters as a comma-separated string, or empty string if none selected
|
||||
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
|
||||
emit('filter-changed', apiFilter)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
selectedFilters.value = []
|
||||
emit('filter-changed', '')
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="space-y-2 px-4">
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
v-show="header.column.getIsVisible()"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
|
||||
header.column.id === 'select' ? 'w-12' : '',
|
||||
header.column.id === 'actions' ? 'w-12' : '',
|
||||
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
|
||||
]"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
:class="{
|
||||
'bg-muted/30': row.getIsSelected(),
|
||||
'table-row-selectable': true,
|
||||
'selecting': isRangeSelecting
|
||||
}"
|
||||
@click="handleRowClick(row.original, $event, row)"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in row.getAllCells()"
|
||||
:key="cell.id"
|
||||
v-show="cell.column.getIsVisible()"
|
||||
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/vue-table'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { type Shot } from '@/services/shot'
|
||||
|
||||
interface Props {
|
||||
columns: ColumnDef<Shot>[]
|
||||
data: Shot[]
|
||||
sorting: SortingState
|
||||
columnVisibility: VisibilityState
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sorting': [sorting: SortingState]
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
'update:rowSelection': [selection: Record<string, boolean>]
|
||||
'row-click': [shot: Shot, event: MouseEvent]
|
||||
'selection-cleared': []
|
||||
}>()
|
||||
|
||||
// Track the last selected row index for range selection
|
||||
const lastSelectedIndex = ref<number | null>(null)
|
||||
const isRangeSelecting = ref(false)
|
||||
const rowSelection = ref<Record<string, boolean>>({})
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.data
|
||||
},
|
||||
get columns() {
|
||||
return props.columns
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
getRowId: (row) => String(row.id),
|
||||
onSortingChange: (updaterOrValue) => {
|
||||
const newSorting =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(props.sorting)
|
||||
: updaterOrValue
|
||||
emit('update:sorting', newSorting)
|
||||
},
|
||||
onColumnVisibilityChange: (updaterOrValue) => {
|
||||
const newVisibility =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(props.columnVisibility)
|
||||
: updaterOrValue
|
||||
emit('update:columnVisibility', newVisibility)
|
||||
},
|
||||
// Re-add the onRowSelectionChange callback but make it work with our custom logic
|
||||
onRowSelectionChange: (updaterOrValue) => {
|
||||
const newSelection =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(rowSelection.value)
|
||||
: updaterOrValue
|
||||
rowSelection.value = newSelection
|
||||
},
|
||||
state: {
|
||||
get sorting() {
|
||||
return props.sorting
|
||||
},
|
||||
get columnVisibility() {
|
||||
return props.columnVisibility
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const handleRowClick = (shot: Shot, event: MouseEvent, row: any) => {
|
||||
// If double-click handler will handle it, skip selection logic
|
||||
if (event.detail === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we clicked on an interactive element (simplified check)
|
||||
const target = event.target as HTMLElement
|
||||
if (target) {
|
||||
// Check if we clicked on a button, checkbox, or other interactive element
|
||||
const interactiveElement = target.closest('button, input, select, textarea, a[href], [role="button"], [role="menuitem"]')
|
||||
if (interactiveElement) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle selection based on modifier keys
|
||||
handleRowSelection(row, event)
|
||||
emit('row-click', shot, event)
|
||||
}
|
||||
|
||||
const handleRowSelection = (row: any, event: MouseEvent) => {
|
||||
const currentIndex = row.index
|
||||
const allRows = table.getRowModel().rows
|
||||
const shotId = String(row.id)
|
||||
|
||||
if (event.shiftKey && lastSelectedIndex.value !== null) {
|
||||
// Prevent text selection when shift-clicking
|
||||
event.preventDefault()
|
||||
|
||||
// Range selection
|
||||
const startIndex = Math.min(lastSelectedIndex.value, currentIndex)
|
||||
const endIndex = Math.max(lastSelectedIndex.value, currentIndex)
|
||||
|
||||
// Create new selection object
|
||||
const newSelection: Record<string, boolean> = {}
|
||||
|
||||
// Select all rows in the range
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (allRows[i]) {
|
||||
newSelection[allRows[i].id] = true
|
||||
}
|
||||
}
|
||||
|
||||
rowSelection.value = newSelection
|
||||
lastSelectedIndex.value = currentIndex
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl/Cmd + Click: Toggle individual selection (additional selection)
|
||||
const newSelection: Record<string, boolean> = { ...rowSelection.value }
|
||||
|
||||
if (newSelection[shotId]) {
|
||||
// Row is selected, deselect it
|
||||
delete newSelection[shotId]
|
||||
} else {
|
||||
// Row is not selected, select it
|
||||
newSelection[shotId] = true
|
||||
}
|
||||
|
||||
rowSelection.value = newSelection
|
||||
lastSelectedIndex.value = currentIndex
|
||||
} else {
|
||||
// Default: Single selection (clear others and select this one)
|
||||
// This applies even if the row is already selected - it becomes the only selection
|
||||
rowSelection.value = { [shotId]: true }
|
||||
lastSelectedIndex.value = currentIndex
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
// Detect if this is a range selection operation
|
||||
if (event.shiftKey) {
|
||||
isRangeSelecting.value = true
|
||||
// Prevent text selection immediately
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// Reset range selecting state
|
||||
isRangeSelecting.value = false
|
||||
}
|
||||
|
||||
// Watch rowSelection changes and emit selection-change events
|
||||
watch(
|
||||
rowSelection,
|
||||
(newSelection) => {
|
||||
emit('update:rowSelection', newSelection)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Prevent text selection during range selection */
|
||||
.table-row-selectable.selecting {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Prevent text selection on shift key operations */
|
||||
.table-row-selectable {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Allow text selection for specific elements that should be selectable */
|
||||
.table-row-selectable input,
|
||||
.table-row-selectable textarea,
|
||||
.table-row-selectable [contenteditable] {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-12">
|
||||
<Checkbox v-model="selectAllChecked" />
|
||||
</TableHead>
|
||||
|
||||
<TableHead
|
||||
v-if="visibleColumns.name"
|
||||
class="cursor-pointer hover:bg-muted/50 select-none"
|
||||
@click="toggleSort('name')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
Shot Name
|
||||
<ArrowUpDown class="h-4 w-4" />
|
||||
</div>
|
||||
</TableHead>
|
||||
|
||||
<TableHead
|
||||
v-if="visibleColumns.episode"
|
||||
class="cursor-pointer hover:bg-muted/50 select-none"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
Episode
|
||||
</div>
|
||||
</TableHead>
|
||||
|
||||
<TableHead
|
||||
v-if="visibleColumns.frameRange"
|
||||
class="cursor-pointer hover:bg-muted/50 select-none"
|
||||
@click="toggleSort('frame_start')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
Frame Range
|
||||
<ArrowUpDown class="h-4 w-4" />
|
||||
</div>
|
||||
</TableHead>
|
||||
|
||||
<TableHead
|
||||
v-if="visibleColumns.status"
|
||||
class="cursor-pointer hover:bg-muted/50 select-none"
|
||||
@click="toggleSort('status')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
Status
|
||||
<ArrowUpDown class="h-4 w-4" />
|
||||
</div>
|
||||
</TableHead>
|
||||
|
||||
<!-- Task Status Columns -->
|
||||
<TableHead
|
||||
v-for="taskType in visibleTaskColumns"
|
||||
:key="taskType"
|
||||
class="cursor-pointer hover:bg-muted/50 select-none w-[140px]"
|
||||
@click="toggleSort(`${taskType}_status`)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ formatTaskType(taskType) }}
|
||||
<ArrowUpDown class="h-4 w-4" />
|
||||
</div>
|
||||
</TableHead>
|
||||
|
||||
<TableHead v-if="visibleColumns.description">Description</TableHead>
|
||||
|
||||
<TableHead class="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="shot in sortedShots"
|
||||
:key="shot.id"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
:class="{
|
||||
'bg-muted/30': selectedShots[shot.id],
|
||||
'opacity-60 bg-destructive/5': authStore.isAdmin && shot.deleted_at
|
||||
}"
|
||||
@click="handleRowClick(shot, $event)"
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
v-model="selectedShots[shot.id]"
|
||||
@click.stop
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell v-if="visibleColumns.name">
|
||||
<div class="flex items-center gap-2">
|
||||
<Camera class="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span class="font-medium" :class="{ 'line-through text-muted-foreground': shot.deleted_at }">
|
||||
{{ shot.name }}
|
||||
</span>
|
||||
<!-- Deletion status indicator for admins -->
|
||||
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs">
|
||||
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell v-if="visibleColumns.episode">
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{{ getEpisodeName(shot.episode_id) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell v-if="visibleColumns.frameRange">
|
||||
<span class="text-sm">
|
||||
{{ shot.frame_start }}-{{ shot.frame_end }}
|
||||
<span class="text-muted-foreground ml-1">
|
||||
({{ shot.frame_end - shot.frame_start + 1 }} frames)
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell v-if="visibleColumns.status">
|
||||
<Badge :variant="getStatusVariant(shot.status)" class="text-xs">
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<!-- Task Status Cells -->
|
||||
<TableCell
|
||||
v-for="taskType in visibleTaskColumns"
|
||||
:key="taskType"
|
||||
@click.stop
|
||||
>
|
||||
<EditableTaskStatus
|
||||
:shot-id="shot.id"
|
||||
:task-type="taskType"
|
||||
:status="shot.task_status?.[taskType] || TaskStatus.NOT_STARTED"
|
||||
:task-id="shot.task_ids?.[taskType]"
|
||||
:project-id="projectId"
|
||||
@status-updated="handleTaskStatusUpdated"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell v-if="visibleColumns.description">
|
||||
<span class="text-sm text-muted-foreground truncate max-w-xs block">
|
||||
{{ shot.description || "—" }}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild @click.stop>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
@click.stop="$emit('edit', shot)"
|
||||
:disabled="!!shot.deleted_at && !authStore.isAdmin"
|
||||
>
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit Shot
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@click.stop="$emit('view-tasks', shot)"
|
||||
:disabled="!!shot.deleted_at && !authStore.isAdmin"
|
||||
>
|
||||
<ListTodo class="h-4 w-4 mr-2" />
|
||||
View Tasks
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<!-- Show recovery option for admins on deleted shots -->
|
||||
<DropdownMenuItem
|
||||
v-if="authStore.isAdmin && shot.deleted_at"
|
||||
@click.stop="$emit('recover', shot)"
|
||||
class="text-green-600 focus:text-green-600"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Recover Shot
|
||||
</DropdownMenuItem>
|
||||
<!-- Show delete option for active shots or permanent delete for admins -->
|
||||
<DropdownMenuItem
|
||||
v-if="!shot.deleted_at || authStore.isAdmin"
|
||||
@click.stop="$emit('delete', shot)"
|
||||
class="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
{{ shot.deleted_at ? 'Permanently Delete' : 'Delete Shot' }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Camera,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
ListTodo,
|
||||
Trash2,
|
||||
RefreshCw
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
|
||||
import EditableTaskStatus from './EditableTaskStatus.vue'
|
||||
import { type Shot, ShotStatus, TaskStatus } from '@/services/shot'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface Props {
|
||||
shots: Shot[]
|
||||
visibleColumns: {
|
||||
name: boolean
|
||||
episode: boolean
|
||||
frameRange: boolean
|
||||
status: boolean
|
||||
description: boolean
|
||||
[key: string]: boolean
|
||||
}
|
||||
episodes: Array<{ id: number; name: string }>
|
||||
allTaskTypes: string[]
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [shot: Shot]
|
||||
edit: [shot: Shot]
|
||||
delete: [shot: Shot]
|
||||
recover: [shot: Shot]
|
||||
'view-tasks': [shot: Shot]
|
||||
'sort-changed': [field: string, direction: 'asc' | 'desc']
|
||||
'task-status-updated': [shotId: number, taskType: string, newStatus: TaskStatus]
|
||||
}>()
|
||||
|
||||
// Auth store for admin check
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// State
|
||||
const selectedShots = ref<Record<number, boolean>>({})
|
||||
const sortField = ref<string | null>(null)
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc')
|
||||
|
||||
// Computed
|
||||
const filteredShots = computed(() => {
|
||||
// Filter out soft deleted shots unless user is admin
|
||||
if (authStore.isAdmin) {
|
||||
return props.shots // Admins can see all shots including deleted ones
|
||||
} else {
|
||||
return props.shots.filter(shot => !shot.deleted_at) // Regular users only see active shots
|
||||
}
|
||||
})
|
||||
|
||||
const visibleTaskColumns = computed(() => {
|
||||
return props.allTaskTypes.filter(taskType =>
|
||||
props.visibleColumns[taskType] !== false
|
||||
)
|
||||
})
|
||||
|
||||
const sortedShots = computed(() => {
|
||||
if (!sortField.value) return filteredShots.value
|
||||
|
||||
return [...filteredShots.value].sort((a, b) => {
|
||||
const field = sortField.value!
|
||||
|
||||
// Handle task status sorting
|
||||
if (field.endsWith('_status')) {
|
||||
const taskType = field.replace('_status', '')
|
||||
const statusOrder = {
|
||||
[TaskStatus.NOT_STARTED]: 0,
|
||||
[TaskStatus.IN_PROGRESS]: 1,
|
||||
[TaskStatus.SUBMITTED]: 2,
|
||||
[TaskStatus.RETAKE]: 3,
|
||||
[TaskStatus.APPROVED]: 4
|
||||
}
|
||||
|
||||
const aStatus = a.task_status?.[taskType] || TaskStatus.NOT_STARTED
|
||||
const bStatus = b.task_status?.[taskType] || TaskStatus.NOT_STARTED
|
||||
const aOrder = statusOrder[aStatus] || 0
|
||||
const bOrder = statusOrder[bStatus] || 0
|
||||
|
||||
return sortDirection.value === 'asc' ? aOrder - bOrder : bOrder - aOrder
|
||||
}
|
||||
|
||||
// Handle regular field sorting
|
||||
let aValue = (a as any)[field]
|
||||
let bValue = (b as any)[field]
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
aValue = aValue.toLowerCase()
|
||||
bValue = bValue.toLowerCase()
|
||||
}
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortDirection.value === 'asc' ? -1 : 1
|
||||
}
|
||||
if (aValue > bValue) {
|
||||
return sortDirection.value === 'asc' ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
// Methods
|
||||
const toggleSort = (field: string) => {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'asc'
|
||||
}
|
||||
|
||||
emit('sort-changed', field, sortDirection.value)
|
||||
}
|
||||
|
||||
// Selection methods
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
filteredShots.value.forEach(shot => {
|
||||
selectedShots.value[shot.id] = checked;
|
||||
});
|
||||
};
|
||||
|
||||
// Selection computed property for select all checkbox
|
||||
const selectAllChecked = computed({
|
||||
get: () => {
|
||||
return filteredShots.value.length > 0 &&
|
||||
filteredShots.value.every(shot => selectedShots.value[shot.id]);
|
||||
},
|
||||
set: (checked: boolean) => {
|
||||
toggleSelectAll(checked);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to get selected shot IDs
|
||||
const getSelectedShotIds = () => {
|
||||
return Object.keys(selectedShots.value)
|
||||
.filter(id => selectedShots.value[Number(id)])
|
||||
.map(id => Number(id));
|
||||
};
|
||||
|
||||
const handleRowClick = (shot: Shot, event: MouseEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// Multi-select with Ctrl/Cmd - toggle selection
|
||||
selectedShots.value[shot.id] = !selectedShots.value[shot.id];
|
||||
} else if (event.shiftKey && getSelectedShotIds().length > 0) {
|
||||
// Range select with Shift
|
||||
const selectedIds = getSelectedShotIds();
|
||||
const lastSelectedId = selectedIds[selectedIds.length - 1];
|
||||
const lastSelectedIndex = filteredShots.value.findIndex(
|
||||
s => s.id === lastSelectedId
|
||||
);
|
||||
const currentIndex = filteredShots.value.findIndex(s => s.id === shot.id);
|
||||
|
||||
if (lastSelectedIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastSelectedIndex, currentIndex);
|
||||
const end = Math.max(lastSelectedIndex, currentIndex);
|
||||
|
||||
// Clear all selections first
|
||||
selectedShots.value = {};
|
||||
// Select range
|
||||
for (let i = start; i <= end; i++) {
|
||||
selectedShots.value[filteredShots.value[i].id] = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single select
|
||||
emit('select', shot);
|
||||
}
|
||||
}
|
||||
|
||||
const getEpisodeName = (episodeId: number) => {
|
||||
const episode = props.episodes.find(e => e.id === episodeId)
|
||||
return episode ? episode.name : `Episode ${episodeId}`
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
}
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: ShotStatus) => {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return 'secondary'
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return 'default'
|
||||
case ShotStatus.ON_HOLD:
|
||||
return 'outline'
|
||||
case ShotStatus.COMPLETED:
|
||||
return 'default'
|
||||
case ShotStatus.APPROVED:
|
||||
return 'default'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskStatusUpdated = (shotId: number, taskType: string, newStatus: string) => {
|
||||
emit('task-status-updated', shotId, taskType, newStatus as TaskStatus)
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.shots, () => {
|
||||
selectedShots.value = {}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,404 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h, ref } from 'vue'
|
||||
import { Camera, MoreHorizontal, Edit, ListTodo, Trash2, ArrowUpDown, ArrowUp, ArrowDown, ChevronDown } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
import EditableTaskStatus from './EditableTaskStatus.vue'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { type Shot, ShotStatus, TaskStatus } from '@/services/shot'
|
||||
|
||||
// Helper function to get the appropriate sort icon
|
||||
const getSortIcon = (sortDirection: false | 'asc' | 'desc') => {
|
||||
if (sortDirection === 'asc') {
|
||||
return h(ArrowDown, { class: 'ml-2 h-4 w-4' })
|
||||
} else if (sortDirection === 'desc') {
|
||||
return h(ArrowUp, { class: 'ml-2 h-4 w-4' })
|
||||
} else {
|
||||
return h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShotColumnMeta {
|
||||
projectId: number
|
||||
episodes: Array<{ id: number; name: string }>
|
||||
onEdit: (shot: Shot) => void
|
||||
onDelete: (shot: Shot) => void
|
||||
onViewTasks: (shot: Shot) => void
|
||||
onTaskStatusUpdated: (shotId: number, taskType: string, newStatus: TaskStatus) => void
|
||||
onTaskAssignmentUpdated?: (shotId: number, taskType: string, userId: number | null) => void
|
||||
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
|
||||
getSelectedCount?: () => number
|
||||
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
|
||||
}
|
||||
|
||||
export const createShotColumns = (
|
||||
allTaskTypes: string[],
|
||||
meta: ShotColumnMeta
|
||||
): ColumnDef<Shot>[] => {
|
||||
const columns: ColumnDef<Shot>[] = [
|
||||
// Select column
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(value === true),
|
||||
ariaLabel: 'Select all',
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(value === true),
|
||||
ariaLabel: 'Select row',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
}),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
// Thumbnail column
|
||||
{
|
||||
id: 'thumbnail',
|
||||
header: 'Thumbnail',
|
||||
cell: () => {
|
||||
return h('div', { class: 'w-20 h-11 bg-muted flex items-center justify-center' }, [
|
||||
h(Camera, { class: 'h-6 w-6 text-muted-foreground' }),
|
||||
])
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Shot Name column
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
// Button,
|
||||
// {
|
||||
// variant: 'ghost',
|
||||
// onClick: () => column.toggleSorting(column.getIsSorted() === 'desc'),
|
||||
// },
|
||||
// () => ['Shot Name', getSortIcon(column.getIsSorted())]
|
||||
'div', {class:'flex items-center justify-center'}, ['Shot Name', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const shot = row.original
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
// h(Camera, { class: 'h-4 w-4 text-muted-foreground flex-shrink-0' }),
|
||||
h('span', { class: 'font-medium' }, shot.name),
|
||||
])
|
||||
},
|
||||
},
|
||||
// Episode column
|
||||
{
|
||||
id: 'episode',
|
||||
accessorFn: (row) => {
|
||||
const episode = meta.episodes.find((e) => e.id === row.episode_id)
|
||||
return episode ? episode.name : `Episode ${row.episode_id}`
|
||||
},
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
'div', {class:'flex items-center justify-center'}, ['Episode', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const shot = row.original
|
||||
const episode = meta.episodes.find((e) => e.id === shot.episode_id)
|
||||
const episodeName = episode ? episode.name : `Episode ${shot.episode_id}`
|
||||
return h(Badge, { variant: 'outline', class: 'text-xs' }, () => episodeName)
|
||||
},
|
||||
},
|
||||
// Frame Range column (not sortable)
|
||||
{
|
||||
id: 'frameRange',
|
||||
header: 'Frame Range',
|
||||
cell: ({ row }) => {
|
||||
const shot = row.original
|
||||
return h('span', { class: 'text-sm' }, `${shot.frame_start}-${shot.frame_end}`)
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Frames column (frame count)
|
||||
{
|
||||
id: 'frames',
|
||||
accessorFn: (row) => row.frame_end - row.frame_start + 1,
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
'div', {class:'flex items-center justify-center'}, ['Frames', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const shot = row.original
|
||||
const frameCount = shot.frame_end - shot.frame_start + 1
|
||||
return h('span', { class: 'text-sm font-medium' }, frameCount.toString())
|
||||
},
|
||||
},
|
||||
// Status column
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
'div', {class:'flex items-center justify-center'}, ['Status', getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const shot = row.original
|
||||
const variant = getStatusVariant(shot.status)
|
||||
const label = formatStatus(shot.status)
|
||||
return h(Badge, { variant, class: 'text-xs' }, () => label)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Add task status columns dynamically
|
||||
allTaskTypes.forEach((taskType) => {
|
||||
// Create ref for popover state for each task type
|
||||
const isPopoverOpen = ref(false)
|
||||
|
||||
columns.push({
|
||||
accessorKey: `task_status.${taskType}`,
|
||||
id: taskType,
|
||||
header: ({ column }) => {
|
||||
const selectedCount = meta.getSelectedCount?.() || 0
|
||||
|
||||
if (selectedCount > 0) {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
[formatTaskType(taskType), getSortIcon(column.getIsSorted())]
|
||||
),
|
||||
h('div', { onClick: (e: Event) => e.stopPropagation() }, [
|
||||
h(Popover, {
|
||||
open: isPopoverOpen.value,
|
||||
'onUpdate:open': (value: boolean) => { isPopoverOpen.value = value }
|
||||
}, {
|
||||
default: () => [
|
||||
h(PopoverTrigger, {}, {
|
||||
default: () => h(
|
||||
Button,
|
||||
{
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
class: 'h-6 w-6 p-0',
|
||||
},
|
||||
() => h(ChevronDown, { class: 'h-3 w-3' })
|
||||
),
|
||||
}),
|
||||
h(PopoverContent, { class: 'w-48 p-2', align: 'start' }, {
|
||||
default: () => {
|
||||
// Get task statuses from meta
|
||||
const allStatusOptions = meta.getAllStatusOptions?.() || []
|
||||
|
||||
return h('div', { class: 'flex flex-col gap-1' }, [
|
||||
h('div', { class: 'px-2 py-1.5 text-sm font-semibold' }, `Change ${formatTaskType(taskType)} Status`),
|
||||
...allStatusOptions.map((statusOption) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'justify-start',
|
||||
onClick: () => {
|
||||
meta.onBulkTaskStatusChange?.(taskType, statusOption.id as TaskStatus)
|
||||
isPopoverOpen.value = false
|
||||
},
|
||||
},
|
||||
() => h(TaskStatusBadge, { status: statusOption, compact: true })
|
||||
)
|
||||
),
|
||||
])
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex items-center justify-center' },
|
||||
[formatTaskType(taskType), getSortIcon(column.getIsSorted())]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const shot = row.original
|
||||
const status = shot.task_status?.[taskType] || TaskStatus.NOT_STARTED
|
||||
const taskId = shot.task_ids?.[taskType]
|
||||
|
||||
// Get assigned user ID from task_details
|
||||
const taskDetail = shot.task_details?.find(detail => detail.task_type === taskType)
|
||||
const assignedUserId = taskDetail?.assigned_user_id || null
|
||||
|
||||
return h(EditableTaskStatus, {
|
||||
key: `${shot.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
|
||||
shotId: shot.id,
|
||||
taskType,
|
||||
status,
|
||||
taskId,
|
||||
projectId: meta.projectId,
|
||||
assignedUserId,
|
||||
onStatusUpdated: (shotId: number, taskType: string, newStatus: TaskStatus) => {
|
||||
meta.onTaskStatusUpdated(shotId, taskType, newStatus)
|
||||
},
|
||||
onAssignmentUpdated: (shotId: number, taskType: string, userId: number | null) => {
|
||||
meta.onTaskAssignmentUpdated?.(shotId, taskType, userId)
|
||||
},
|
||||
})
|
||||
},
|
||||
enableSorting: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
// Actions column
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const shot = row.original
|
||||
return h(
|
||||
DropdownMenu,
|
||||
{},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuTrigger,
|
||||
{
|
||||
asChild: true
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'h-8 w-8 p-0',
|
||||
onMouseDown: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
},
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => h(MoreHorizontal, { class: 'h-4 w-4' }),
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuContent,
|
||||
{
|
||||
align: 'end'
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
meta.onEdit(shot)
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(Edit, { class: 'h-4 w-4 mr-2' }),
|
||||
'Edit Shot',
|
||||
],
|
||||
}
|
||||
),
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
meta.onViewTasks(shot)
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(ListTodo, { class: 'h-4 w-4 mr-2' }),
|
||||
'View Tasks',
|
||||
],
|
||||
}
|
||||
),
|
||||
h(DropdownMenuSeparator),
|
||||
h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
meta.onDelete(shot)
|
||||
},
|
||||
class: 'text-destructive focus:text-destructive',
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(Trash2, { class: 'h-4 w-4 mr-2' }),
|
||||
'Delete Shot',
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
})
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatTaskType(taskType: string): string {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
|
||||
function formatStatus(status: ShotStatus): string {
|
||||
return status
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function getStatusVariant(status: ShotStatus): 'default' | 'secondary' | 'outline' {
|
||||
switch (status) {
|
||||
case ShotStatus.NOT_STARTED:
|
||||
return 'secondary'
|
||||
case ShotStatus.IN_PROGRESS:
|
||||
return 'default'
|
||||
case ShotStatus.ON_HOLD:
|
||||
return 'outline'
|
||||
case ShotStatus.COMPLETED:
|
||||
return 'default'
|
||||
case ShotStatus.APPROVED:
|
||||
return 'default'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<Badge
|
||||
v-if="!statusObject?.color"
|
||||
:variant="getStatusVariant(statusString)"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ displayName }}
|
||||
</Badge>
|
||||
<div
|
||||
v-else
|
||||
:class="badgeClass"
|
||||
:style="customStyle"
|
||||
class="inline-flex items-center justify-center rounded-md border px-2.5 py-0.5 font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
{{ displayName }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface StatusObject {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
is_system?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
status: string | StatusObject
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
compact: false
|
||||
})
|
||||
|
||||
// Determine if status is an object with color property
|
||||
const statusObject = computed(() => {
|
||||
if (typeof props.status === 'object' && props.status !== null) {
|
||||
return props.status as StatusObject
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Get the status string (either from object or direct string)
|
||||
const statusString = computed(() => {
|
||||
if (statusObject.value) {
|
||||
return statusObject.value.id || statusObject.value.name
|
||||
}
|
||||
return props.status as string
|
||||
})
|
||||
|
||||
// Get the display name
|
||||
const displayName = computed(() => {
|
||||
if (statusObject.value) {
|
||||
return statusObject.value.name
|
||||
}
|
||||
return formatStatus(statusString.value)
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
return props.compact ? 'w-[100px] text-xs' : 'w-[130px] text-xs'
|
||||
})
|
||||
|
||||
// Calculate contrast color (black or white) based on background color
|
||||
const getContrastColor = (hexColor: string): string => {
|
||||
// Remove # if present
|
||||
const hex = hexColor.replace('#', '')
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
|
||||
// Calculate relative luminance using WCAG formula
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
|
||||
// Return black for light backgrounds, white for dark backgrounds
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
// Custom style for badges with custom colors
|
||||
const customStyle = computed(() => {
|
||||
if (statusObject.value?.color) {
|
||||
const bgColor = statusObject.value.color
|
||||
const textColor = getContrastColor(bgColor)
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
color: textColor,
|
||||
borderColor: bgColor
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const getStatusVariant = (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 formatStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'not_started':
|
||||
return 'Not Started'
|
||||
case 'in_progress':
|
||||
return 'In Progress'
|
||||
case 'submitted':
|
||||
return 'Submitted'
|
||||
case 'approved':
|
||||
return 'Approved'
|
||||
case 'retake':
|
||||
return 'Retake'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden hover:shadow-md transition-shadow">
|
||||
<div class="aspect-video bg-muted flex items-center justify-center relative group">
|
||||
<!-- Image Preview -->
|
||||
<img
|
||||
v-if="thumbnailBlobUrl && !imageError"
|
||||
:src="thumbnailBlobUrl"
|
||||
:alt="attachment.file_name"
|
||||
class="w-full h-full object-cover cursor-pointer"
|
||||
@click="emit('view', attachment)"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
|
||||
<!-- File Icon -->
|
||||
<div v-else class="flex flex-col items-center gap-2">
|
||||
<FileIcon class="h-12 w-12 text-muted-foreground" />
|
||||
<span class="text-xs text-muted-foreground">{{ getFileExtension(attachment.file_name) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Overlay Actions -->
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="attachment.thumbnail_url"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
@click="emit('view', attachment)"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
@click="emit('delete', attachment.id)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent class="p-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium truncate" :title="attachment.file_name">
|
||||
{{ attachment.file_name }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{{ formatAttachmentType(attachment.attachment_type) }}
|
||||
</Badge>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ formatFileSize(attachment.file_size) }}</span>
|
||||
<span>{{ formatDate(attachment.uploaded_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="attachment.description" class="text-xs text-muted-foreground line-clamp-2">
|
||||
{{ attachment.description }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Avatar class="h-4 w-4">
|
||||
<AvatarImage
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${attachment.user_first_name} ${attachment.user_last_name}`"
|
||||
/>
|
||||
<AvatarFallback class="text-[8px]">{{ getUserInitials(attachment.user_first_name, attachment.user_last_name) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>By {{ attachment.user_first_name }} {{ attachment.user_last_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { FileIcon, Download, Eye, Trash2 } from 'lucide-vue-next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import type { TaskAttachment } from '@/services/task'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
const props = defineProps<{
|
||||
attachment: TaskAttachment
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: number]
|
||||
view: [attachment: TaskAttachment]
|
||||
}>()
|
||||
|
||||
const imageError = ref(false)
|
||||
const thumbnailBlobUrl = ref<string | null>(null)
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
const parts = filename.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE'
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatAttachmentType(type: string): string {
|
||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (props.attachment.download_url) {
|
||||
const url = props.attachment.download_url.startsWith('http')
|
||||
? props.attachment.download_url
|
||||
: `http://localhost:8000${props.attachment.download_url}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
function getUserInitials(firstName: string, lastName: string) {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
async function loadThumbnail() {
|
||||
if (!props.attachment.thumbnail_url) return
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(props.attachment.thumbnail_url, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
thumbnailBlobUrl.value = URL.createObjectURL(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load thumbnail:', error)
|
||||
imageError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.attachment.thumbnail_url) {
|
||||
loadThumbnail()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up blob URL to prevent memory leaks
|
||||
if (thumbnailBlobUrl.value) {
|
||||
URL.revokeObjectURL(thumbnailBlobUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="relative"
|
||||
|
||||
>
|
||||
<Select
|
||||
:model-value="currentStatusId"
|
||||
@update:model-value="handleStatusChange"
|
||||
:disabled="isUpdating || isLoadingStatuses"
|
||||
|
||||
>
|
||||
<SelectTrigger class="h-6 w-[130px] font-semibold text-xs"
|
||||
:style="{ backgroundColor: currentStatusObject.color }"
|
||||
>
|
||||
<SelectValue
|
||||
:model-value="currentStatusId"
|
||||
>
|
||||
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<!-- System Statuses -->
|
||||
<SelectItem
|
||||
v-for="statusOption in allStatusOptions"
|
||||
:key="statusOption.id"
|
||||
:value="statusOption.id"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Color indicator -->
|
||||
<!-- <div
|
||||
v-if="statusOption.color"
|
||||
class="w-3 h-3 rounded-full border border-border"
|
||||
:style="{ backgroundColor: statusOption.color }"
|
||||
/> -->
|
||||
<TaskStatusBadge :status="statusOption" compact />
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div
|
||||
v-if="isUpdating || isLoadingStatuses"
|
||||
class="absolute inset-0 bg-background/50 flex items-center justify-center rounded"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { taskService } from '@/services/task'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
|
||||
interface StatusOption {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
is_system?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
taskId: number
|
||||
status: TaskStatus | string
|
||||
projectId: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'status-updated', taskId: number, newStatus: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store instead of direct API calls
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
|
||||
// Get loading state from store
|
||||
const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId))
|
||||
|
||||
// Get all status options from store
|
||||
const allStatusOptions = computed(() => taskStatusesStore.getAllStatusOptions(props.projectId))
|
||||
|
||||
// Get current status ID (handle both TaskStatus enum and custom status strings)
|
||||
const currentStatusId = computed(() => {
|
||||
if (typeof props.status === 'string') {
|
||||
return props.status
|
||||
}
|
||||
return props.status as string
|
||||
})
|
||||
|
||||
// Get current status object for display using store
|
||||
const currentStatusObject = computed((): StatusOption => {
|
||||
const statusFromStore = taskStatusesStore.getStatusById(props.projectId, currentStatusId.value)
|
||||
if (statusFromStore) {
|
||||
return {
|
||||
id: statusFromStore.id,
|
||||
name: statusFromStore.name,
|
||||
color: statusFromStore.color,
|
||||
is_system: 'is_system' in statusFromStore ? statusFromStore.is_system : false
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to current status as-is
|
||||
return {
|
||||
id: currentStatusId.value,
|
||||
name: formatStatusName(currentStatusId.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Format status name for display
|
||||
const formatStatusName = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'not_started':
|
||||
return 'Not Started'
|
||||
case 'in_progress':
|
||||
return 'In Progress'
|
||||
case 'submitted':
|
||||
return 'Submitted'
|
||||
case 'approved':
|
||||
return 'Approved'
|
||||
case 'retake':
|
||||
return 'Retake'
|
||||
default:
|
||||
// Convert snake_case to Title Case
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch custom statuses for the project using store
|
||||
const fetchStatuses = async () => {
|
||||
if (!props.projectId) return
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatusId: any) => {
|
||||
if (!newStatusId || newStatusId === currentStatusId.value) return
|
||||
|
||||
const statusId = newStatusId as string
|
||||
|
||||
isUpdating.value = true
|
||||
|
||||
try {
|
||||
// Update task status via API
|
||||
await taskService.updateTaskStatus(props.taskId, statusId as TaskStatus)
|
||||
emit('status-updated', props.taskId, statusId)
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error)
|
||||
// Revert by emitting the original status
|
||||
emit('status-updated', props.taskId, currentStatusId.value)
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch statuses on mount
|
||||
onMounted(() => {
|
||||
fetchStatuses()
|
||||
})
|
||||
|
||||
// Refetch statuses when projectId changes
|
||||
watch(() => props.projectId, () => {
|
||||
fetchStatuses()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage
|
||||
v-if="note.user_id"
|
||||
:src="getAvatarUrl(note.user_id)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${note.user_first_name} ${note.user_last_name}`"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{{ getInitials(note.user_first_name, note.user_last_name) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-sm">
|
||||
{{ note.user_first_name }} {{ note.user_last_name }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatDateTime(note.created_at) }}
|
||||
</span>
|
||||
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground">
|
||||
(edited)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Note Content -->
|
||||
<div v-if="!editing" class="text-sm whitespace-pre-wrap">
|
||||
{{ note.content }}
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div v-else class="space-y-2">
|
||||
<Textarea
|
||||
v-model="editContent"
|
||||
rows="3"
|
||||
class="resize-none"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" @click="handleSave">Save</Button>
|
||||
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="!editing" class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="emit('reply', note.id)"
|
||||
>
|
||||
<Reply class="h-3 w-3 mr-1" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canEdit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="startEdit"
|
||||
>
|
||||
<Pencil class="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<Trash2 class="h-3 w-3 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Child Notes (Threaded) -->
|
||||
<div v-if="note.child_notes && note.child_notes.length > 0" class="mt-4 space-y-4 pl-4 border-l-2">
|
||||
<NoteItem
|
||||
v-for="childNote in note.child_notes"
|
||||
:key="childNote.id"
|
||||
:note="childNote"
|
||||
:task-id="taskId"
|
||||
@note-updated="emit('noteUpdated')"
|
||||
@reply="emit('reply', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Reply, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { taskService, type ProductionNote } from '@/services/task'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
note: ProductionNote
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
noteUpdated: []
|
||||
reply: [noteId: number]
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const editing = ref(false)
|
||||
const editContent = ref('')
|
||||
|
||||
const canEdit = computed(() => {
|
||||
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
const canDelete = computed(() => {
|
||||
return authStore.user?.id === props.note.user_id || authStore.user?.is_admin
|
||||
})
|
||||
|
||||
function getInitials(firstName: string, lastName: string): string {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
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('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editing.value = true
|
||||
editContent.value = props.note.content
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await taskService.updateTaskNote(props.taskId, props.note.id, editContent.value)
|
||||
editing.value = false
|
||||
emit('noteUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Note updated successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating note:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to update note',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Are you sure you want to delete this note?')) return
|
||||
|
||||
try {
|
||||
await taskService.deleteTaskNote(props.taskId, props.note.id)
|
||||
emit('noteUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Note deleted successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting note:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to delete note',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getAvatarUrl(userId: number | null | undefined) {
|
||||
// For NoteItem, we don't have the avatar URL, so we can't display avatars
|
||||
// This would require updating the backend API to include avatar URLs in note responses
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden hover:shadow-md transition-shadow">
|
||||
<div class="flex gap-4 p-4">
|
||||
<!-- Thumbnail -->
|
||||
<div class="w-32 h-24 bg-muted rounded flex items-center justify-center flex-shrink-0 relative group cursor-pointer" @click="emit('view', submission)">
|
||||
<img
|
||||
v-if="thumbnailBlobUrl"
|
||||
:src="thumbnailBlobUrl"
|
||||
:alt="submission.file_name"
|
||||
class="w-full h-full object-cover rounded"
|
||||
/>
|
||||
<div v-else class="flex flex-col items-center gap-1">
|
||||
<FileIcon class="h-8 w-8 text-muted-foreground" />
|
||||
<span class="text-xs text-muted-foreground">{{ getFileExtension(submission.file_name) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Play icon for videos -->
|
||||
<div v-if="submission.stream_url" class="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/50 transition-colors">
|
||||
<Play class="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-semibold">Version {{ submission.version_number }}</h4>
|
||||
<Badge v-if="submission.latest_review" :variant="getReviewVariant(submission.latest_review.decision)">
|
||||
{{ submission.latest_review.decision }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ submission.file_name }}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar class="h-5 w-5">
|
||||
<AvatarImage
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${submission.user_first_name} ${submission.user_last_name}`"
|
||||
/>
|
||||
<AvatarFallback class="text-[10px]">{{ getUserInitials(submission.user_first_name, submission.user_last_name) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>Submitted by {{ submission.user_first_name }} {{ submission.user_last_name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ formatDateTime(submission.submitted_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="submission.notes" class="text-sm bg-muted p-2 rounded">
|
||||
<p class="font-semibold text-xs mb-1">Notes:</p>
|
||||
<p class="line-clamp-2">{{ submission.notes }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="submission.latest_review?.feedback" class="text-sm border-l-2 pl-2" :class="getReviewBorderClass(submission.latest_review.decision)">
|
||||
<p class="font-semibold text-xs mb-1">Review Feedback:</p>
|
||||
<p class="line-clamp-2">{{ submission.latest_review.feedback }}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
By {{ submission.latest_review.reviewer_first_name }} {{ submission.latest_review.reviewer_last_name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="emit('view', submission)"
|
||||
>
|
||||
<Eye class="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { FileIcon, Download, Eye, Play } from 'lucide-vue-next'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import type { Submission } from '@/services/task'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
const props = defineProps<{
|
||||
submission: Submission
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
view: [submission: Submission]
|
||||
}>()
|
||||
|
||||
const thumbnailBlobUrl = ref<string | null>(null)
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
const parts = filename.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE'
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function getReviewVariant(decision: string): 'default' | 'destructive' {
|
||||
return decision === 'approved' ? 'default' : 'destructive'
|
||||
}
|
||||
|
||||
function getReviewBorderClass(decision: string): string {
|
||||
return decision === 'approved' ? 'border-green-500' : 'border-destructive'
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (props.submission.download_url) {
|
||||
const url = getThumbnailUrl(props.submission.download_url)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
function getUserInitials(firstName: string, lastName: string) {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
async function loadThumbnail() {
|
||||
if (!props.submission.thumbnail_url) {
|
||||
thumbnailBlobUrl.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(props.submission.thumbnail_url, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
// Revoke old blob URL if it exists
|
||||
if (thumbnailBlobUrl.value) {
|
||||
URL.revokeObjectURL(thumbnailBlobUrl.value)
|
||||
}
|
||||
|
||||
// Create new blob URL
|
||||
thumbnailBlobUrl.value = URL.createObjectURL(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load thumbnail:', error)
|
||||
thumbnailBlobUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadThumbnail()
|
||||
})
|
||||
|
||||
watch(() => props.submission.id, () => {
|
||||
loadThumbnail()
|
||||
})
|
||||
|
||||
function getThumbnailUrl(url: string | null | undefined) {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http')) return url
|
||||
// Remove any leading slashes and backend prefix
|
||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
return `http://localhost:8000/${cleanUrl}`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Attachments History (Top) -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<!-- Filter by Type -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-medium">Filter:</span>
|
||||
<Select v-model="filterType">
|
||||
<SelectTrigger class="w-[140px] h-8 text-xs">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="type in attachmentTypes"
|
||||
:key="type.value"
|
||||
:value="type.value"
|
||||
>
|
||||
{{ type.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Attachments Grid -->
|
||||
<div v-if="filteredAttachments.length === 0" class="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Upload class="h-10 w-10 mb-2 opacity-50" />
|
||||
<p class="text-sm">No attachments found. Upload files below.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-2">
|
||||
<AttachmentCard
|
||||
v-for="attachment in filteredAttachments"
|
||||
:key="attachment.id"
|
||||
:attachment="attachment"
|
||||
@delete="handleDelete"
|
||||
@view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Area (Bottom) -->
|
||||
<div class="flex-shrink-0 border-t bg-background p-2">
|
||||
<div class="space-y-2">
|
||||
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
multiple
|
||||
/>
|
||||
<Upload class="h-6 w-6 mx-auto mb-1 text-muted-foreground" />
|
||||
<p class="text-xs font-medium mb-1">Upload attachments</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="fileInput?.click()"
|
||||
:disabled="uploading"
|
||||
class="h-7 text-xs"
|
||||
>
|
||||
<Upload class="h-3 w-3 mr-1" />
|
||||
{{ uploading ? 'Uploading...' : 'Choose Files' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Attachment Type Selection -->
|
||||
<Select v-model="attachmentType">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue placeholder="Select attachment type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="reference">Reference</SelectItem>
|
||||
<SelectItem value="work_file">Work File</SelectItem>
|
||||
<SelectItem value="preview">Preview</SelectItem>
|
||||
<SelectItem value="documentation">Documentation</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Viewer Dialog -->
|
||||
<Dialog v-model:open="viewerOpen">
|
||||
<DialogContent class="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ selectedAttachment?.file_name }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Attachment preview
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex items-center justify-center">
|
||||
<img
|
||||
v-if="selectedAttachment?.thumbnail_url && mediaBlobUrl"
|
||||
:src="mediaBlobUrl"
|
||||
:alt="selectedAttachment.file_name"
|
||||
class="max-w-full max-h-[70vh] object-contain"
|
||||
/>
|
||||
<!-- Fallback for other file types -->
|
||||
<div v-else class="text-center p-8">
|
||||
<p class="text-muted-foreground mb-4">Preview not available for this file type</p>
|
||||
<Button @click="handleDownload(selectedAttachment)" variant="outline">
|
||||
Download File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import AttachmentCard from './AttachmentCard.vue'
|
||||
import { taskService, type TaskAttachment } from '@/services/task'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
attachments: TaskAttachment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
attachmentsUpdated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const uploading = ref(false)
|
||||
const attachmentType = ref('reference')
|
||||
const filterType = ref('all')
|
||||
const viewerOpen = ref(false)
|
||||
const selectedAttachment = ref<TaskAttachment | null>(null)
|
||||
const mediaBlobUrl = ref<string | null>(null)
|
||||
|
||||
const attachmentTypes = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'reference', label: 'Reference' },
|
||||
{ value: 'work_file', label: 'Work Files' },
|
||||
{ value: 'preview', label: 'Previews' },
|
||||
{ value: 'documentation', label: 'Documentation' }
|
||||
]
|
||||
|
||||
const filteredAttachments = computed(() => {
|
||||
if (filterType.value === 'all') return props.attachments
|
||||
return props.attachments.filter(a => a.attachment_type === filterType.value)
|
||||
})
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const files = target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
await taskService.uploadTaskAttachment(
|
||||
props.taskId,
|
||||
file,
|
||||
attachmentType.value
|
||||
)
|
||||
}
|
||||
emit('attachmentsUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `${files.length} file(s) uploaded successfully`
|
||||
})
|
||||
// Reset input
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading files:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to upload files',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(attachmentId: number) {
|
||||
if (!confirm('Are you sure you want to delete this attachment?')) return
|
||||
|
||||
try {
|
||||
await taskService.deleteTaskAttachment(props.taskId, attachmentId)
|
||||
emit('attachmentsUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Attachment deleted successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting attachment:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to delete attachment',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleView(attachment: TaskAttachment) {
|
||||
selectedAttachment.value = attachment
|
||||
viewerOpen.value = true
|
||||
|
||||
// Load media for viewer
|
||||
if (attachment.thumbnail_url || attachment.download_url) {
|
||||
await loadMediaForViewer(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMediaForViewer(attachment: TaskAttachment) {
|
||||
try {
|
||||
if (attachment.download_url) {
|
||||
// Load image as blob
|
||||
const response = await apiClient.get(attachment.download_url, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
if (mediaBlobUrl.value) {
|
||||
URL.revokeObjectURL(mediaBlobUrl.value)
|
||||
}
|
||||
mediaBlobUrl.value = URL.createObjectURL(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load media:', error)
|
||||
mediaBlobUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(attachment: TaskAttachment | null) {
|
||||
if (!attachment?.download_url) return
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(attachment.download_url, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = attachment.file_name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,519 @@
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<!-- Main Content -->
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar - Sticky -->
|
||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||
<TaskTableToolbar
|
||||
v-model:status-filter="statusFilter"
|
||||
v-model:type-filter="typeFilter"
|
||||
v-model:episode-filter="episodeFilter"
|
||||
v-model:assignee-filter="assigneeFilter"
|
||||
v-model:context-filter="contextFilter"
|
||||
v-model:search="searchQuery"
|
||||
v-model:my-tasks-filter="myTasksFilter"
|
||||
:column-visibility="columnVisibility"
|
||||
:episodes="episodes"
|
||||
:assignees="assignees"
|
||||
:task-types="taskTypes"
|
||||
:current-user-id="currentUserId"
|
||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||
@update:column-visibility="updateColumnVisibility"
|
||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Task Count / Selection Count -->
|
||||
<div class="flex items-center justify-between px-4 sm:px-6">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span v-if="selectedCount > 0" class="font-medium text-foreground">
|
||||
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
|
||||
</span>
|
||||
<span v-else-if="filteredTasks.length === tasks.length">
|
||||
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<TasksDataTable
|
||||
:tasks="filteredTasks"
|
||||
:column-visibility="columnVisibility"
|
||||
:project-id="projectId"
|
||||
:is-loading="isLoading"
|
||||
@row-click="handleRowClick"
|
||||
@row-double-click="handleRowDoubleClick"
|
||||
@context-menu="handleContextMenu"
|
||||
@selection-change="handleSelectionChange"
|
||||
@update:column-visibility="updateColumnVisibility"
|
||||
@status-updated="handleStatusUpdated"
|
||||
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Task Detail Panel - Desktop (Fixed Right Side) -->
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-300 ease-out"
|
||||
enter-from-class="translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transition-transform duration-300 ease-in"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPanel"
|
||||
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
|
||||
>
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTask"
|
||||
:task-id="selectedTask?.id || 0"
|
||||
:is-open="true"
|
||||
@close="closeDetailPanel"
|
||||
@task-updated="handleTaskUpdated"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Task Detail Panel - Mobile (Sheet) -->
|
||||
<Sheet v-model:open="showMobileDetail">
|
||||
<SheetContent side="right" class="w-full sm:max-w-md p-0">
|
||||
<TaskDetailPanel
|
||||
v-if="selectedTask"
|
||||
:task-id="selectedTask?.id || 0"
|
||||
:is-open="true"
|
||||
@close="closeDetailPanel"
|
||||
@task-updated="handleTaskUpdated"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<!-- Context Menu for Bulk Actions -->
|
||||
<TaskBulkActionsMenu
|
||||
v-model:open="showContextMenu"
|
||||
:position="contextMenuPosition"
|
||||
:selected-count="selectedCount"
|
||||
:selected-tasks="selectedTasks"
|
||||
:project-members="projectMembers"
|
||||
@status-selected="handleBulkStatusUpdate"
|
||||
@assignee-selected="handleBulkAssignment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import TaskTableToolbar from './TaskTableToolbar.vue'
|
||||
import TaskDetailPanel from './TaskDetailPanel.vue'
|
||||
import TaskBulkActionsMenu from './TaskBulkActionsMenu.vue'
|
||||
import TasksDataTable from './TasksDataTable.vue'
|
||||
import { type Task } from '@/services/task'
|
||||
import { taskService } from '@/services/task'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
import { episodeService, type Episode } from '@/services/episode'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { shotService, type Shot } from '@/services/shot'
|
||||
import { assetService, type Asset } from '@/services/asset'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDetailPanel } from '@/composables/useDetailPanel'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Detail panel composable
|
||||
const {
|
||||
isDetailPanelEnabled,
|
||||
isDetailPanelVisible,
|
||||
selectedEntity: selectedTask,
|
||||
showMobileDetail,
|
||||
showPanel,
|
||||
toggleDetailPanelEnabled,
|
||||
closeDetailPanel,
|
||||
selectEntity: selectTask,
|
||||
handleRowClick: handleRowClickComposable
|
||||
} = useDetailPanel<Task>({
|
||||
isDialogOpen: () => showContextMenu.value,
|
||||
sessionStorageKey: 'taskBrowser.detailPanelEnabled'
|
||||
})
|
||||
|
||||
// State
|
||||
const tasks = ref<Task[]>([])
|
||||
const episodes = ref<Episode[]>([])
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Context menu state
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
// Filters
|
||||
const statusFilter = ref<string[]>([])
|
||||
const typeFilter = ref<string[]>([])
|
||||
const episodeFilter = ref<number | null>(null)
|
||||
const assigneeFilter = ref<number[]>([])
|
||||
const contextFilter = ref<'all' | 'shots' | 'assets'>('all')
|
||||
const searchQuery = ref('')
|
||||
const myTasksFilter = ref(false)
|
||||
|
||||
// Table state
|
||||
const columnVisibility = ref<VisibilityState>({})
|
||||
|
||||
// Selection state - using Set for efficient lookups
|
||||
const selectedTaskIds = ref<Set<number>>(new Set())
|
||||
|
||||
// Computed
|
||||
const currentUserId = computed(() => authStore.user?.id || null)
|
||||
|
||||
const assignees = computed(() => {
|
||||
const uniqueAssignees = new Map<number, { id: number; name: string }>()
|
||||
tasks.value.forEach((task) => {
|
||||
if (task.assigned_user_id && task.assigned_user_name) {
|
||||
uniqueAssignees.set(task.assigned_user_id, {
|
||||
id: task.assigned_user_id,
|
||||
name: task.assigned_user_name,
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(uniqueAssignees.values())
|
||||
})
|
||||
|
||||
const taskTypes = computed(() => {
|
||||
const types = new Set<string>()
|
||||
tasks.value.forEach((task) => types.add(task.task_type))
|
||||
return Array.from(types).sort()
|
||||
})
|
||||
|
||||
// Selection computed properties (Requirement 2.5)
|
||||
const selectedTasks = computed(() => {
|
||||
return filteredTasks.value.filter(task => selectedTaskIds.value.has(task.id))
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => selectedTaskIds.value.size)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let filtered = tasks.value
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(task) =>
|
||||
task.name.toLowerCase().includes(query) ||
|
||||
task.description?.toLowerCase().includes(query) ||
|
||||
task.shot_name?.toLowerCase().includes(query) ||
|
||||
task.asset_name?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter.value.length > 0) {
|
||||
filtered = filtered.filter((task) => statusFilter.value.includes(task.status))
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter.value.length > 0) {
|
||||
filtered = filtered.filter((task) => typeFilter.value.includes(task.task_type))
|
||||
}
|
||||
|
||||
// Episode filter
|
||||
if (episodeFilter.value !== null) {
|
||||
filtered = filtered.filter((task) => task.episode_id === episodeFilter.value)
|
||||
}
|
||||
|
||||
// Assignee filter
|
||||
if (assigneeFilter.value.length > 0) {
|
||||
filtered = filtered.filter((task) =>
|
||||
task.assigned_user_id ? assigneeFilter.value.includes(task.assigned_user_id) : false
|
||||
)
|
||||
}
|
||||
|
||||
// Context filter
|
||||
if (contextFilter.value === 'shots') {
|
||||
filtered = filtered.filter((task) => task.shot_id !== null && task.shot_id !== undefined)
|
||||
} else if (contextFilter.value === 'assets') {
|
||||
filtered = filtered.filter((task) => task.asset_id !== null && task.asset_id !== undefined)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
// Methods
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// Get both shots and assets with embedded task data (two optimized backend calls)
|
||||
const [shots, assets] = await Promise.all([
|
||||
shotService.getShots({ projectId: props.projectId }),
|
||||
assetService.getAssets(props.projectId)
|
||||
])
|
||||
|
||||
// Extract tasks from embedded data - no separate task API calls needed!
|
||||
const shotTasks = shots.flatMap(shot =>
|
||||
(shot.task_details || []).map(taskDetail => ({
|
||||
id: taskDetail.task_id || 0,
|
||||
name: `${shot.name} - ${taskDetail.task_type}`,
|
||||
description: `${taskDetail.task_type} task for shot ${shot.name}`,
|
||||
task_type: taskDetail.task_type,
|
||||
status: taskDetail.status,
|
||||
project_id: shot.project_id,
|
||||
project_name: shot.project_name,
|
||||
episode_id: shot.episode_id,
|
||||
shot_id: shot.id,
|
||||
shot_name: shot.name,
|
||||
assigned_user_id: taskDetail.assigned_user_id,
|
||||
assigned_user_name: undefined, // Will be populated from project members if needed
|
||||
assigned_user_email: undefined,
|
||||
created_at: shot.created_at,
|
||||
updated_at: shot.updated_at
|
||||
} as Task))
|
||||
)
|
||||
|
||||
const assetTasks = assets.flatMap(asset =>
|
||||
(asset.task_details || []).map(taskDetail => ({
|
||||
id: taskDetail.task_id || 0,
|
||||
name: `${asset.name} - ${taskDetail.task_type}`,
|
||||
description: `${taskDetail.task_type} task for asset ${asset.name}`,
|
||||
task_type: taskDetail.task_type,
|
||||
status: taskDetail.status,
|
||||
project_id: asset.project_id,
|
||||
asset_id: asset.id,
|
||||
asset_name: asset.name,
|
||||
assigned_user_id: taskDetail.assigned_user_id,
|
||||
assigned_user_name: undefined, // Will be populated from project members if needed
|
||||
assigned_user_email: undefined,
|
||||
created_at: asset.created_at,
|
||||
updated_at: asset.updated_at
|
||||
} as Task))
|
||||
)
|
||||
|
||||
// Combine all tasks from embedded data
|
||||
tasks.value = [...shotTasks, ...assetTasks]
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load tasks',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
try {
|
||||
const response = await episodeService.getEpisodes(props.projectId)
|
||||
episodes.value = response
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch episodes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProjectMembers = async () => {
|
||||
try {
|
||||
const response = await projectService.getProjectMembers(props.projectId)
|
||||
projectMembers.value = response
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project members:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowClick = () => {
|
||||
// Row click is now handled by TasksDataTable and the composable
|
||||
// This handler can be used for additional logic if needed
|
||||
}
|
||||
|
||||
const handleRowDoubleClick = (task: Task) => {
|
||||
selectTask(task)
|
||||
// Show mobile sheet on small screens if enabled
|
||||
if (isDetailPanelEnabled.value && window.innerWidth < 1024) {
|
||||
showMobileDetail.value = true
|
||||
}
|
||||
// Don't reset manual visibility when selecting a new task
|
||||
}
|
||||
|
||||
const handleTaskUpdated = () => {
|
||||
fetchTasks()
|
||||
}
|
||||
|
||||
// Status updated handler - for individual task status changes
|
||||
const handleStatusUpdated = (taskId: number, newStatus: string) => {
|
||||
// Find and update the task in the array
|
||||
const taskIndex = tasks.value.findIndex((t) => t.id === taskId)
|
||||
if (taskIndex !== -1) {
|
||||
// Create a completely new array to trigger TanStack Table reactivity
|
||||
const updatedTasks = [...tasks.value]
|
||||
updatedTasks[taskIndex] = {
|
||||
...updatedTasks[taskIndex],
|
||||
status: newStatus as TaskStatus,
|
||||
}
|
||||
tasks.value = updatedTasks
|
||||
}
|
||||
}
|
||||
|
||||
// Selection change handler - receives selected task IDs from TasksDataTable
|
||||
const handleSelectionChange = (taskIds: number[]) => {
|
||||
// Update selectedTaskIds Set with new selection
|
||||
selectedTaskIds.value = new Set(taskIds)
|
||||
}
|
||||
|
||||
// Context menu handlers (Requirements 4.1, 4.2)
|
||||
const handleContextMenu = (event: MouseEvent, _tasks: Task[]) => {
|
||||
// Selection is already handled by TasksDataTable
|
||||
// Just position and show the context menu
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
const closeContextMenu = () => {
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
// Bulk action handlers (Requirements 4.2, 4.3, 4.4, 4.5, 6.1, 6.3)
|
||||
const handleBulkStatusUpdate = async (status: string) => {
|
||||
try {
|
||||
// Extract selected task IDs
|
||||
const taskIds = selectedTasks.value.map(task => task.id)
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Show loading state during operation
|
||||
isLoading.value = true
|
||||
|
||||
// Call bulk update service
|
||||
const result = await taskService.bulkUpdateStatus(taskIds, status)
|
||||
|
||||
// Display success toast with count of updated tasks
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `${result.success_count} ${result.success_count === 1 ? 'task' : 'tasks'} updated`,
|
||||
})
|
||||
|
||||
// Refresh task list after successful update
|
||||
await fetchTasks()
|
||||
|
||||
// Close context menu (keep selection)
|
||||
closeContextMenu()
|
||||
} catch (error) {
|
||||
// Handle errors and display error toast
|
||||
console.error('Failed to update task status:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to update tasks. Please try again.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk assignment handler (Requirements 5.3, 5.4, 5.5, 5.6, 6.1, 6.3)
|
||||
const handleBulkAssignment = async (userId: number) => {
|
||||
try {
|
||||
// Extract selected task IDs from selection state
|
||||
const taskIds = selectedTasks.value.map(task => task.id)
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Show loading state during operation
|
||||
isLoading.value = true
|
||||
|
||||
// Call taskService.bulkAssignTasks with task IDs and user ID
|
||||
const result = await taskService.bulkAssignTasks(taskIds, userId)
|
||||
|
||||
// Display success toast with count of assigned tasks
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `${result.success_count} ${result.success_count === 1 ? 'task' : 'tasks'} assigned`,
|
||||
})
|
||||
|
||||
// Refresh task list after successful update
|
||||
await fetchTasks()
|
||||
|
||||
// Close context menu (keep selection)
|
||||
closeContextMenu()
|
||||
} catch (error) {
|
||||
// Handle errors and display error toast
|
||||
console.error('Failed to assign tasks:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to assign tasks. Please try again.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateColumnVisibility = (visibility: VisibilityState) => {
|
||||
columnVisibility.value = visibility
|
||||
// Persist to session storage
|
||||
sessionStorage.setItem(
|
||||
`tasks-table-column-visibility-${props.projectId}`,
|
||||
JSON.stringify(visibility)
|
||||
)
|
||||
}
|
||||
|
||||
// Load column visibility from session storage
|
||||
const loadColumnVisibility = () => {
|
||||
const saved = sessionStorage.getItem(`tasks-table-column-visibility-${props.projectId}`)
|
||||
if (saved) {
|
||||
try {
|
||||
columnVisibility.value = JSON.parse(saved)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved column visibility:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadColumnVisibility()
|
||||
fetchTasks()
|
||||
fetchEpisodes()
|
||||
fetchProjectMembers()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cleanup handled by composable
|
||||
})
|
||||
|
||||
// Watch for project changes
|
||||
watch(
|
||||
() => props.projectId,
|
||||
() => {
|
||||
loadColumnVisibility()
|
||||
fetchTasks()
|
||||
fetchEpisodes()
|
||||
fetchProjectMembers()
|
||||
}
|
||||
)
|
||||
|
||||
// Watch mobile sheet close
|
||||
watch(showMobileDetail, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
// Let the composable handle cleanup
|
||||
}
|
||||
})
|
||||
|
||||
// Clear selection when filters change (Requirements 5.1, 5.2, 5.3, 5.4, 5.5)
|
||||
watch([statusFilter, typeFilter, episodeFilter, assigneeFilter, contextFilter, searchQuery], () => {
|
||||
selectedTaskIds.value = new Set()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<Popover v-model:open="isOpen">
|
||||
<PopoverAnchor
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
left: `${props.position.x}px`,
|
||||
top: `${props.position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}"
|
||||
/>
|
||||
<PopoverContent
|
||||
class="w-56 p-1"
|
||||
:side="'bottom'"
|
||||
:align="'start'"
|
||||
@interact-outside="handleInteractOutside"
|
||||
>
|
||||
<!-- Header showing selection count -->
|
||||
<div class="px-2 py-1.5 text-sm font-semibold text-muted-foreground border-b mb-1">
|
||||
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
|
||||
</div>
|
||||
|
||||
<!-- Validation warning if tasks from multiple projects -->
|
||||
<div v-if="hasMultipleProjects" class="px-2 py-1.5 text-xs text-destructive bg-destructive/10 rounded-sm mb-1">
|
||||
Selected tasks are from different projects
|
||||
</div>
|
||||
|
||||
<!-- Set Status submenu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
:disabled="isProcessing || hasMultipleProjects || isLoadingStatuses"
|
||||
class="w-full text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground rounded-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between"
|
||||
>
|
||||
<span>Set Status</span>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-48" side="right" align="start">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoadingStatuses" class="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
Loading statuses...
|
||||
</div>
|
||||
|
||||
<!-- System statuses -->
|
||||
<template v-else>
|
||||
<div v-if="systemStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
|
||||
System Statuses
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
v-for="status in systemStatuses"
|
||||
:key="status.id"
|
||||
:disabled="isProcessing"
|
||||
@click="handleStatusSelected(status.id)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full flex-shrink-0"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
/>
|
||||
<span>{{ status.name }}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Divider if both system and custom statuses exist -->
|
||||
<div v-if="systemStatuses.length > 0 && customStatuses.length > 0" class="h-px bg-border my-1" />
|
||||
|
||||
<!-- Custom statuses -->
|
||||
<div v-if="customStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
|
||||
Custom Statuses
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
v-for="status in customStatuses"
|
||||
:key="status.id"
|
||||
:disabled="isProcessing"
|
||||
@click="handleStatusSelected(status.id)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full flex-shrink-0"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
/>
|
||||
<span>{{ status.name }}</span>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="h-px bg-border my-1" />
|
||||
|
||||
<!-- Assign To section -->
|
||||
<div class="py-1">
|
||||
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Assign To
|
||||
</div>
|
||||
<div v-if="projectMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
No members available
|
||||
</div>
|
||||
<div v-else class="max-h-48 overflow-y-auto">
|
||||
<button
|
||||
v-for="member in projectMembers"
|
||||
:key="member.user_id"
|
||||
:disabled="isProcessing || hasMultipleProjects"
|
||||
class="w-full text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground rounded-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="handleAssigneeSelected(member.user_id)"
|
||||
>
|
||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import type { ProjectMember } from '@/services/project'
|
||||
import type { Task } from '@/services/task'
|
||||
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
|
||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
position: { x: number; y: number }
|
||||
selectedCount: number
|
||||
selectedTasks: Task[]
|
||||
projectMembers: ProjectMember[]
|
||||
isProcessing?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'status-selected', status: string): void
|
||||
(e: 'assignee-selected', userId: number): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isProcessing: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Use the shared task statuses store
|
||||
const taskStatusesStore = useTaskStatusesStore()
|
||||
|
||||
// Local state for menu open/close
|
||||
const isOpen = ref(props.open)
|
||||
|
||||
// Computed properties
|
||||
const hasMultipleProjects = computed(() => {
|
||||
if (props.selectedTasks.length === 0) return false
|
||||
const projectIds = new Set(props.selectedTasks.map(task => task.project_id))
|
||||
return projectIds.size > 1
|
||||
})
|
||||
|
||||
const currentProjectId = computed(() => {
|
||||
if (props.selectedTasks.length === 0) return null
|
||||
return props.selectedTasks[0].project_id
|
||||
})
|
||||
|
||||
// Get loading state from store
|
||||
const isLoadingStatuses = computed(() => {
|
||||
return currentProjectId.value ? taskStatusesStore.isLoading(currentProjectId.value) : false
|
||||
})
|
||||
|
||||
// Get status options from store
|
||||
const systemStatuses = computed(() => {
|
||||
if (!currentProjectId.value) return []
|
||||
const statuses = taskStatusesStore.getProjectStatuses(currentProjectId.value)
|
||||
return statuses?.system_statuses || []
|
||||
})
|
||||
|
||||
const customStatuses = computed(() => {
|
||||
if (!currentProjectId.value) return []
|
||||
const statuses = taskStatusesStore.getProjectStatuses(currentProjectId.value)
|
||||
return statuses?.statuses || []
|
||||
})
|
||||
|
||||
// Methods
|
||||
const fetchStatuses = async () => {
|
||||
if (!currentProjectId.value || hasMultipleProjects.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await taskStatusesStore.fetchProjectStatuses(currentProjectId.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task statuses:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusSelected = (status: string) => {
|
||||
emit('status-selected', status)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleAssigneeSelected = (userId: number) => {
|
||||
emit('assignee-selected', userId)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleInteractOutside = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// Watch for prop changes
|
||||
watch(
|
||||
() => props.open,
|
||||
(newValue) => {
|
||||
isOpen.value = newValue
|
||||
// Fetch statuses when menu opens
|
||||
if (newValue && currentProjectId.value && !hasMultipleProjects.value) {
|
||||
fetchStatuses()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Emit changes to parent
|
||||
watch(isOpen, (newValue) => {
|
||||
emit('update:open', newValue)
|
||||
})
|
||||
|
||||
// Watch for selected tasks changes to refetch statuses if project changes
|
||||
watch(
|
||||
() => currentProjectId.value,
|
||||
(newProjectId, oldProjectId) => {
|
||||
if (newProjectId && newProjectId !== oldProjectId && isOpen.value) {
|
||||
fetchStatuses()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Fetch statuses on mount if menu is already open
|
||||
onMounted(() => {
|
||||
if (isOpen.value && currentProjectId.value && !hasMultipleProjects.value) {
|
||||
fetchStatuses()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" 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 task details...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Details -->
|
||||
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Header (Fixed) -->
|
||||
<div class="flex-shrink-0 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">{{ task.name }}</h2>
|
||||
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
|
||||
</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 min-h-0">
|
||||
<!-- Tabs List (Fixed) -->
|
||||
<TabsList class="flex-shrink-0 mx-0 mt-0 grid w-full grid-cols-4 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="attachments">
|
||||
Attachments
|
||||
<Badge v-if="attachments.length > 0" variant="secondary" class="ml-2">
|
||||
{{ attachments.length }}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="submissions">
|
||||
Submissions
|
||||
<Badge v-if="submissions.length > 0" variant="secondary" class="ml-2">
|
||||
{{ submissions.length }}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Infos Tab -->
|
||||
<TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
|
||||
<!-- Task Description -->
|
||||
<div v-if="task.description" class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Description</h3>
|
||||
<p class="text-sm text-muted-foreground">{{ task.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Bar -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Quick Actions</h3>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="canStartTask"
|
||||
@click="handleQuickAction('start')"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
>
|
||||
<Play class="h-4 w-4 mr-2" />
|
||||
Start Task
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canSubmitWork"
|
||||
@click="handleQuickAction('submit')"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
>
|
||||
<Upload class="h-4 w-4 mr-2" />
|
||||
Submit Work
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canReassign"
|
||||
@click="showAssignmentDialog = true"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
>
|
||||
<UserPlus class="h-4 w-4 mr-2" />
|
||||
Reassign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Update -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Status</h3>
|
||||
<Select v-model="localStatus" @update:model-value="(value) => handleStatusChange(value as string)">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="not_started">Not Started</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="submitted">Submitted</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="retake">Retake</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Task Information -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold">Task Information</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Type</Label>
|
||||
<p class="text-sm mt-1">
|
||||
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Deadline</Label>
|
||||
<p class="text-sm mt-1 flex items-center gap-2" :class="getDeadlineClass(task.deadline, task.status)">
|
||||
<Calendar class="h-3 w-3" />
|
||||
{{ task.deadline ? formatDate(task.deadline) : 'No deadline' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Assigned To</Label>
|
||||
<div v-if="task.assigned_user_name" class="mt-1 flex items-center gap-2">
|
||||
<Avatar class="h-6 w-6">
|
||||
<AvatarImage
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${task.assigned_user_name}`"
|
||||
/>
|
||||
<AvatarFallback class="text-xs">{{ getAssignedUserInitials(task.assigned_user_name) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm font-medium">{{ task.assigned_user_name }}</span>
|
||||
</div>
|
||||
<div v-else class="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<User class="h-3 w-3" />
|
||||
Unassigned
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Created</Label>
|
||||
<p>{{ formatDate(task.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-muted-foreground">Updated</Label>
|
||||
<p>{{ formatDate(task.updated_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Context</h3>
|
||||
<div class="text-sm space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Project:</span>
|
||||
<span class="font-medium">{{ task.project_name }}</span>
|
||||
</div>
|
||||
<div v-if="task.episode_name" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Episode:</span>
|
||||
<span class="font-medium">{{ task.episode_name }}</span>
|
||||
</div>
|
||||
<div v-if="task.shot_name" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Shot:</span>
|
||||
<span class="font-medium">{{ task.shot_name }}</span>
|
||||
</div>
|
||||
<div v-if="task.asset_name" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Asset:</span>
|
||||
<span class="font-medium">{{ task.asset_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Notes Tab -->
|
||||
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
|
||||
<TaskNotes :task-id="taskId" :notes="notes" @notes-updated="loadNotes" />
|
||||
</TabsContent>
|
||||
|
||||
<!-- Attachments Tab -->
|
||||
<TabsContent value="attachments" class="flex-1 m-0 overflow-hidden">
|
||||
<TaskAttachments :task-id="taskId" :attachments="attachments" @attachments-updated="loadAttachments" />
|
||||
</TabsContent>
|
||||
|
||||
<!-- Submissions Tab -->
|
||||
<TabsContent value="submissions" class="flex-1 m-0 overflow-hidden">
|
||||
<TaskSubmissions :task-id="taskId" :submissions="submissions" @submissions-updated="loadSubmissions" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Dialog -->
|
||||
<Dialog v-model:open="showAssignmentDialog">
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Task</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a project member to assign this task to.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-4">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search members..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No members found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="member in projectMembers"
|
||||
:key="member.user_id"
|
||||
:value="member.user_id.toString()"
|
||||
@click="selectedUserId = member.user_id"
|
||||
class="cursor-pointer"
|
||||
:class="{ 'bg-accent': selectedUserId === member.user_id }"
|
||||
>
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${member.user_first_name} ${member.user_last_name}`"
|
||||
/>
|
||||
<AvatarFallback>{{ getUserInitials(member) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ member.department_role }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedUserId === member.user_id" class="text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showAssignmentDialog = false">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@click="handleAssignTask"
|
||||
:disabled="!selectedUserId || assignmentLoading"
|
||||
>
|
||||
{{ assignmentLoading ? 'Assigning...' : 'Assign' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { X, Play, Upload, UserPlus, Calendar, User } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import TaskStatusBadge from './TaskStatusBadge.vue'
|
||||
import TaskNotes from './TaskNotes.vue'
|
||||
import TaskAttachments from './TaskAttachments.vue'
|
||||
import TaskSubmissions from './TaskSubmissions.vue'
|
||||
import { taskService, type Task, type ProductionNote, type TaskAttachment, type Submission } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
taskUpdated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const task = ref<Task | null>(null)
|
||||
const loading = ref(false)
|
||||
const localStatus = ref('')
|
||||
const notes = ref<ProductionNote[]>([])
|
||||
const attachments = ref<TaskAttachment[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
const showAssignmentDialog = ref(false)
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const selectedUserId = ref<number | null>(null)
|
||||
const assignmentLoading = ref(false)
|
||||
|
||||
// Computed properties for quick actions
|
||||
const canStartTask = computed(() => {
|
||||
if (!task.value || !authStore.user) return false
|
||||
return (
|
||||
task.value.assigned_user_id === authStore.user.id &&
|
||||
task.value.status === 'not_started'
|
||||
)
|
||||
})
|
||||
|
||||
const canSubmitWork = computed(() => {
|
||||
if (!task.value || !authStore.user) return false
|
||||
return (
|
||||
task.value.assigned_user_id === authStore.user.id &&
|
||||
(task.value.status === 'in_progress' || task.value.status === 'retake')
|
||||
)
|
||||
})
|
||||
|
||||
const canReassign = computed(() => {
|
||||
if (!authStore.user) return false
|
||||
return authStore.user.is_admin || authStore.user.role === 'coordinator'
|
||||
})
|
||||
|
||||
async function loadTask() {
|
||||
loading.value = true
|
||||
try {
|
||||
task.value = await taskService.getTask(props.taskId)
|
||||
localStatus.value = task.value.status
|
||||
} catch (error: any) {
|
||||
console.error('Error loading task:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to load task',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotes() {
|
||||
try {
|
||||
notes.value = await taskService.getTaskNotes(props.taskId)
|
||||
} catch (error) {
|
||||
console.error('Error loading notes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAttachments() {
|
||||
try {
|
||||
attachments.value = await taskService.getTaskAttachments(props.taskId)
|
||||
} catch (error) {
|
||||
console.error('Error loading attachments:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubmissions() {
|
||||
try {
|
||||
submissions.value = await taskService.getTaskSubmissions(props.taskId)
|
||||
} catch (error) {
|
||||
console.error('Error loading submissions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
if (!task.value) return
|
||||
|
||||
try {
|
||||
await taskService.updateTaskStatus(props.taskId, newStatus as any)
|
||||
task.value.status = newStatus as any
|
||||
emit('taskUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Task status updated successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating status:', error)
|
||||
localStatus.value = task.value.status // Revert
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to update task status',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickAction(action: 'start' | 'submit') {
|
||||
if (!task.value) return
|
||||
|
||||
if (action === 'start') {
|
||||
await handleStatusChange('in_progress')
|
||||
} else if (action === 'submit') {
|
||||
// Trigger the submissions tab to show the upload interface
|
||||
toast({
|
||||
title: 'Submit Work',
|
||||
description: 'Please use the Submissions tab to upload your work'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectMembers() {
|
||||
if (!task.value) return
|
||||
|
||||
try {
|
||||
projectMembers.value = await projectService.getProjectMembers(task.value.project_id)
|
||||
} catch (error) {
|
||||
console.error('Error loading project members:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignTask() {
|
||||
if (!selectedUserId.value || !task.value) return
|
||||
|
||||
assignmentLoading.value = true
|
||||
try {
|
||||
await taskService.assignTask(props.taskId, selectedUserId.value)
|
||||
await loadTask()
|
||||
showAssignmentDialog.value = false
|
||||
emit('taskUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Task assigned successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error assigning task:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to assign task',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
assignmentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getUserDisplayName(member: ProjectMember): string {
|
||||
return `${member.user_first_name} ${member.user_last_name} (${member.department_role})`
|
||||
}
|
||||
|
||||
function getUserInitials(member: ProjectMember): string {
|
||||
return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
|
||||
function getAvatarUrl(url: string | null | undefined) {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http')) return url
|
||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `http://localhost:8000/${cleanUrl}`
|
||||
}
|
||||
|
||||
function getAssignedUserInitials(name: string) {
|
||||
const parts = name.split(' ')
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0].charAt(0)}${parts[1].charAt(0)}`.toUpperCase()
|
||||
}
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function formatTaskType(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' ')
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getDeadlineClass(deadline: string | undefined, status: string): string {
|
||||
if (!deadline || status === 'approved') return 'text-muted-foreground'
|
||||
|
||||
const now = new Date()
|
||||
const deadlineDate = new Date(deadline)
|
||||
const daysUntil = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (daysUntil < 0) return 'text-destructive'
|
||||
if (daysUntil <= 3) return 'text-orange-600'
|
||||
if (daysUntil <= 7) return 'text-yellow-600'
|
||||
return 'text-foreground'
|
||||
}
|
||||
|
||||
watch(() => props.taskId, () => {
|
||||
loadTask()
|
||||
loadNotes()
|
||||
loadAttachments()
|
||||
loadSubmissions()
|
||||
})
|
||||
|
||||
watch(showAssignmentDialog, (newValue: boolean) => {
|
||||
if (newValue) {
|
||||
loadProjectMembers()
|
||||
selectedUserId.value = task.value?.assigned_user_id || null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTask()
|
||||
loadNotes()
|
||||
loadAttachments()
|
||||
loadSubmissions()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,614 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search tasks..."
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select v-model="statusFilter">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="not_started">Not Started</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="submitted">Submitted</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="retake">Retake</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-model="taskTypeFilter">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="simulation">Simulation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="compositing">Compositing</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-if="showDepartmentFilter" v-model="departmentFilter">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Filter by department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Departments</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-model="sortBy">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deadline">Deadline</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="updated">Last Updated</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Task Table -->
|
||||
<div class="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Deadline</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Context</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="task in filteredAndSortedTasks"
|
||||
:key="task.id"
|
||||
class="cursor-pointer hover:bg-muted/50"
|
||||
@click="selectTask(task)"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="font-medium">{{ task.name }}</div>
|
||||
<div v-if="task.assigned_user_name" class="flex items-center gap-2 mt-1">
|
||||
<Avatar class="h-5 w-5">
|
||||
<AvatarImage
|
||||
v-if="task.assigned_user_id"
|
||||
:src="getAvatarUrl(task.assigned_user_id)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${task.assigned_user_name}`"
|
||||
/>
|
||||
<AvatarFallback class="text-[10px]">{{ getAssignedUserInitials(task.assigned_user_name) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm text-muted-foreground">{{ task.assigned_user_name }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{{ formatTaskType(task.task_type) }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell @click.stop>
|
||||
<Select
|
||||
:model-value="task.status"
|
||||
@update:model-value="(value) => handleStatusUpdate(task.id, value)"
|
||||
>
|
||||
<SelectTrigger class="w-[130px]">
|
||||
<SelectValue>
|
||||
<TaskStatusBadge :status="task.status" />
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="not_started">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-gray-400"></div>
|
||||
<span>Not Started</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="in_progress">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||
<span>In Progress</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="submitted">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-purple-500"></div>
|
||||
<span>Submitted</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="approved">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="retake">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<span>Retake</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div v-if="task.deadline" class="flex items-center gap-2">
|
||||
<div :class="getDeadlineContainerClass(task.deadline, task.status)" class="flex items-center gap-2 px-2 py-1 rounded">
|
||||
<AlertCircle
|
||||
v-if="isOverdue(task.deadline, task.status)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<Clock
|
||||
v-else-if="isUrgent(task.deadline, task.status)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span :class="getDeadlineTextClass(task.deadline, task.status)">
|
||||
{{ formatDate(task.deadline) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">No deadline</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="text-sm">{{ task.project_name }}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<div v-if="task.shot_name">Shot: {{ task.shot_name }}</div>
|
||||
<div v-if="task.asset_name">Asset: {{ task.asset_name }}</div>
|
||||
<div v-if="task.episode_name" class="text-xs">{{ task.episode_name }}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell @click.stop>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="canAssignTasks"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="openAssignDialog(task)"
|
||||
title="Assign task to a team member"
|
||||
>
|
||||
<UserPlus class="h-4 w-4 mr-1" />
|
||||
<span class="hidden sm:inline">Assign</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="selectTask(task)"
|
||||
title="View task details"
|
||||
>
|
||||
<Eye class="h-4 w-4 mr-1" />
|
||||
<span class="hidden sm:inline">View</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="filteredAndSortedTasks.length === 0">
|
||||
<TableCell colspan="7" class="text-center text-muted-foreground py-8">
|
||||
No tasks found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Task Assignment Dialog -->
|
||||
<Dialog v-model:open="assignDialogOpen">
|
||||
<DialogContent class="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Task</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign "{{ selectedTaskForAssignment?.name }}" to a team member
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Filter by Department</Label>
|
||||
<Select v-model="assignmentDepartmentFilter">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All departments" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Departments</SelectItem>
|
||||
<SelectItem value="layout">Layout</SelectItem>
|
||||
<SelectItem value="animation">Animation</SelectItem>
|
||||
<SelectItem value="lighting">Lighting</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="modeling">Modeling</SelectItem>
|
||||
<SelectItem value="surfacing">Surfacing</SelectItem>
|
||||
<SelectItem value="rigging">Rigging</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Assign to</Label>
|
||||
<Select v-model="selectedUserId">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="member in filteredProjectMembers"
|
||||
:key="member.user_id"
|
||||
:value="member.user_id.toString()"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Avatar class="h-6 w-6">
|
||||
<AvatarImage
|
||||
v-if="member.user_id"
|
||||
:src="getAvatarUrl(member.user_id)"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${member.user_first_name} ${member.user_last_name}`"
|
||||
/>
|
||||
<AvatarFallback class="text-xs">{{ member.user_first_name.charAt(0) }}{{ member.user_last_name.charAt(0) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ member.user_first_name }} {{ member.user_last_name }}</span>
|
||||
<Badge variant="outline" class="ml-auto">{{ formatDepartmentRole(member.department_role) }}</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="assignDialogOpen = false">Cancel</Button>
|
||||
<Button @click="handleAssignTask" :disabled="!selectedUserId || assigningTask">
|
||||
<Loader2 v-if="assigningTask" class="mr-2 h-4 w-4 animate-spin" />
|
||||
Assign Task
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useTasksStore } from '@/stores/tasks'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { AlertCircle, Clock, UserPlus, Loader2, Eye } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import TaskStatusBadge from './TaskStatusBadge.vue'
|
||||
import type { TaskListItem } from '@/services/task'
|
||||
import { taskService } from '@/services/task'
|
||||
import { projectService, type ProjectMember } from '@/services/project'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
taskSelected: [task: TaskListItem]
|
||||
}>()
|
||||
|
||||
const tasksStore = useTasksStore()
|
||||
const authStore = useAuthStore()
|
||||
const { toast } = useToast()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const taskTypeFilter = ref('all')
|
||||
const departmentFilter = ref('all')
|
||||
const sortBy = ref('deadline')
|
||||
|
||||
// Task assignment
|
||||
const assignDialogOpen = ref(false)
|
||||
const selectedTaskForAssignment = ref<TaskListItem | null>(null)
|
||||
const selectedUserId = ref<string>('')
|
||||
const assignmentDepartmentFilter = ref('all')
|
||||
const projectMembers = ref<ProjectMember[]>([])
|
||||
const assigningTask = ref(false)
|
||||
|
||||
const canAssignTasks = computed(() => {
|
||||
return authStore.user?.is_admin || authStore.user?.role === 'coordinator'
|
||||
})
|
||||
|
||||
const showDepartmentFilter = computed(() => {
|
||||
return canAssignTasks.value
|
||||
})
|
||||
|
||||
const filteredProjectMembers = computed(() => {
|
||||
if (!selectedTaskForAssignment.value) return projectMembers.value
|
||||
|
||||
if (assignmentDepartmentFilter.value === 'all') {
|
||||
return projectMembers.value
|
||||
}
|
||||
|
||||
return projectMembers.value.filter(
|
||||
member => member.department_role === assignmentDepartmentFilter.value
|
||||
)
|
||||
})
|
||||
|
||||
const filteredAndSortedTasks = computed(() => {
|
||||
let filtered = tasksStore.tasks
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(task =>
|
||||
task.name.toLowerCase().includes(query) ||
|
||||
task.project_name?.toLowerCase().includes(query) ||
|
||||
task.shot_name?.toLowerCase().includes(query) ||
|
||||
task.asset_name?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter.value !== 'all') {
|
||||
filtered = filtered.filter(task => task.status === statusFilter.value)
|
||||
}
|
||||
|
||||
// Task type filter
|
||||
if (taskTypeFilter.value !== 'all') {
|
||||
filtered = filtered.filter(task => task.task_type === taskTypeFilter.value)
|
||||
}
|
||||
|
||||
// Department filter (for coordinators/admins)
|
||||
if (departmentFilter.value !== 'all' && canAssignTasks.value) {
|
||||
// This would require backend support to filter by department role
|
||||
// For now, we'll skip this filter as it requires additional API support
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
switch (sortBy.value) {
|
||||
case 'deadline':
|
||||
if (!a.deadline) return 1
|
||||
if (!b.deadline) return -1
|
||||
return new Date(a.deadline).getTime() - new Date(b.deadline).getTime()
|
||||
case 'status':
|
||||
return a.status.localeCompare(b.status)
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name)
|
||||
case 'updated':
|
||||
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
function selectTask(task: any) {
|
||||
emit('taskSelected', task)
|
||||
}
|
||||
|
||||
function formatTaskType(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' ')
|
||||
}
|
||||
|
||||
function formatDepartmentRole(role: string): string {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1)
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const daysUntil = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
|
||||
if (daysUntil < 0) {
|
||||
return `${dateStr} (${Math.abs(daysUntil)}d overdue)`
|
||||
} else if (daysUntil === 0) {
|
||||
return `${dateStr} (Today)`
|
||||
} else if (daysUntil === 1) {
|
||||
return `${dateStr} (Tomorrow)`
|
||||
} else if (daysUntil <= 7) {
|
||||
return `${dateStr} (${daysUntil}d)`
|
||||
}
|
||||
|
||||
return dateStr
|
||||
}
|
||||
|
||||
function isOverdue(deadline: string, status: string): boolean {
|
||||
if (status === 'approved') return false
|
||||
const now = new Date()
|
||||
const deadlineDate = new Date(deadline)
|
||||
return deadlineDate < now
|
||||
}
|
||||
|
||||
function isUrgent(deadline: string, status: string): boolean {
|
||||
if (status === 'approved') return false
|
||||
const now = new Date()
|
||||
const deadlineDate = new Date(deadline)
|
||||
const daysUntil = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
return daysUntil >= 0 && daysUntil <= 3
|
||||
}
|
||||
|
||||
function getDeadlineContainerClass(deadline: string, status: string): string {
|
||||
if (status === 'approved') return 'bg-muted/50'
|
||||
|
||||
const now = new Date()
|
||||
const deadlineDate = new Date(deadline)
|
||||
const daysUntil = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (daysUntil < 0) return 'bg-destructive/10'
|
||||
if (daysUntil <= 3) return 'bg-orange-500/10'
|
||||
if (daysUntil <= 7) return 'bg-yellow-500/10'
|
||||
return ''
|
||||
}
|
||||
|
||||
function getDeadlineTextClass(deadline: string, status: string): string {
|
||||
if (status === 'approved') return 'text-muted-foreground'
|
||||
|
||||
const now = new Date()
|
||||
const deadlineDate = new Date(deadline)
|
||||
const daysUntil = Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (daysUntil < 0) return 'text-destructive font-semibold'
|
||||
if (daysUntil <= 3) return 'text-orange-600 font-semibold'
|
||||
if (daysUntil <= 7) return 'text-yellow-600 font-medium'
|
||||
return 'text-foreground'
|
||||
}
|
||||
|
||||
async function handleStatusUpdate(taskId: number, newStatus: string) {
|
||||
try {
|
||||
await tasksStore.updateTaskStatus(taskId, newStatus)
|
||||
toast({
|
||||
title: 'Status Updated',
|
||||
description: 'Task status has been updated successfully.',
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to update task status',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function openAssignDialog(task: TaskListItem) {
|
||||
selectedTaskForAssignment.value = task
|
||||
selectedUserId.value = task.assigned_user_id?.toString() || ''
|
||||
assignmentDepartmentFilter.value = 'all'
|
||||
|
||||
// Fetch project members
|
||||
if (task.project_id) {
|
||||
try {
|
||||
projectMembers.value = await projectService.getProjectMembers(task.project_id)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project members:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load project members',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
assignDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleAssignTask() {
|
||||
if (!selectedTaskForAssignment.value || !selectedUserId.value) return
|
||||
|
||||
assigningTask.value = true
|
||||
try {
|
||||
await taskService.assignTask(
|
||||
selectedTaskForAssignment.value.id,
|
||||
parseInt(selectedUserId.value)
|
||||
)
|
||||
|
||||
toast({
|
||||
title: 'Task Assigned',
|
||||
description: 'Task has been assigned successfully.',
|
||||
})
|
||||
|
||||
// Refresh tasks
|
||||
await tasksStore.fetchTasks({ projectId: props.projectId })
|
||||
|
||||
assignDialogOpen.value = false
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to assign task',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
assigningTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery.value = ''
|
||||
statusFilter.value = 'all'
|
||||
taskTypeFilter.value = 'all'
|
||||
departmentFilter.value = 'all'
|
||||
sortBy.value = 'deadline'
|
||||
}
|
||||
|
||||
function getAvatarUrl(userIdOrUrl: string | number | null | undefined) {
|
||||
if (!userIdOrUrl) return ''
|
||||
// If it's a number, we don't have the avatar URL, so we can't display avatars
|
||||
if (typeof userIdOrUrl === 'number') {
|
||||
return ''
|
||||
}
|
||||
// If it's already a full URL, return it
|
||||
if (userIdOrUrl.startsWith('http')) return userIdOrUrl
|
||||
// Check if it looks like a user ID (numeric string)
|
||||
if (/^\d+$/.test(userIdOrUrl)) {
|
||||
return ''
|
||||
}
|
||||
// Use direct static file serving for avatar URLs
|
||||
const cleanUrl = userIdOrUrl.replace(/^backend[\/\\]/, '').replace(/\\/g, '/')
|
||||
return `/${cleanUrl}`
|
||||
}
|
||||
|
||||
function getAssignedUserInitials(name: string) {
|
||||
const parts = name.split(' ')
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0].charAt(0)}${parts[1].charAt(0)}`.toUpperCase()
|
||||
}
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await tasksStore.fetchTasks({
|
||||
projectId: props.projectId
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Notes History (Top) -->
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
<div v-if="notes.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<MessageSquarePlus class="h-10 w-10 mb-2 opacity-50" />
|
||||
<p class="text-sm">No notes yet. Start the conversation below.</p>
|
||||
</div>
|
||||
|
||||
<NoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
:note="note"
|
||||
:task-id="taskId"
|
||||
@note-updated="emit('notesUpdated')"
|
||||
@reply="handleReply"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Note Input (Bottom) -->
|
||||
<div class="flex-shrink-0 border-t bg-background p-2">
|
||||
<div class="space-y-2">
|
||||
<Textarea
|
||||
v-model="newNoteContent"
|
||||
placeholder="Add a note..."
|
||||
rows="2"
|
||||
class="resize-none text-sm"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
@click="handleAddNote"
|
||||
:disabled="!newNoteContent.trim() || submitting"
|
||||
size="sm"
|
||||
>
|
||||
<MessageSquarePlus class="h-4 w-4 mr-2" />
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { MessageSquarePlus } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import NoteItem from './NoteItem.vue'
|
||||
import { taskService, type ProductionNote } from '@/services/task'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
notes: ProductionNote[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
notesUpdated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const newNoteContent = ref('')
|
||||
const submitting = ref(false)
|
||||
const replyToNoteId = ref<number | null>(null)
|
||||
|
||||
async function handleAddNote() {
|
||||
if (!newNoteContent.value.trim()) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await taskService.createTaskNote(
|
||||
props.taskId,
|
||||
newNoteContent.value,
|
||||
replyToNoteId.value || undefined
|
||||
)
|
||||
newNoteContent.value = ''
|
||||
replyToNoteId.value = null
|
||||
emit('notesUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Note added successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error adding note:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to add note',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReply(noteId: number) {
|
||||
replyToNoteId.value = noteId
|
||||
// Focus on textarea (you could add a ref for this)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<Badge
|
||||
v-if="!statusObject?.color"
|
||||
:variant="getStatusVariant(statusString)"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ displayName }}
|
||||
</Badge>
|
||||
<div
|
||||
v-else
|
||||
:class="badgeClass"
|
||||
:style="customStyle"
|
||||
class="inline-flex items-center justify-center rounded-md border px-2.5 py-0.5 font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
{{ displayName }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface StatusObject {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
is_system?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
status: string | StatusObject
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
compact: false
|
||||
})
|
||||
|
||||
// Determine if status is an object with color property
|
||||
const statusObject = computed(() => {
|
||||
if (typeof props.status === 'object' && props.status !== null) {
|
||||
return props.status as StatusObject
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Get the status string (either from object or direct string)
|
||||
const statusString = computed(() => {
|
||||
if (statusObject.value) {
|
||||
return statusObject.value.id || statusObject.value.name
|
||||
}
|
||||
return props.status as string
|
||||
})
|
||||
|
||||
// Get the display name
|
||||
const displayName = computed(() => {
|
||||
if (statusObject.value) {
|
||||
return statusObject.value.name
|
||||
}
|
||||
return formatStatus(statusString.value)
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
return props.compact ? 'w-[100px] text-xs' : 'w-[130px] text-xs'
|
||||
})
|
||||
|
||||
// Calculate contrast color (black or white) based on background color
|
||||
const getContrastColor = (hexColor: string): string => {
|
||||
// Remove # if present
|
||||
const hex = hexColor.replace('#', '')
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
|
||||
// Calculate relative luminance using WCAG formula
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
|
||||
// Return black for light backgrounds, white for dark backgrounds
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
// Custom style for badges with custom colors
|
||||
const customStyle = computed(() => {
|
||||
if (statusObject.value?.color) {
|
||||
const bgColor = statusObject.value.color
|
||||
const textColor = getContrastColor(bgColor)
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
color: textColor,
|
||||
borderColor: bgColor
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const getStatusVariant = (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 formatStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'not_started':
|
||||
return 'Not Started'
|
||||
case 'in_progress':
|
||||
return 'In Progress'
|
||||
case 'submitted':
|
||||
return 'Submitted'
|
||||
case 'approved':
|
||||
return 'Approved'
|
||||
case 'retake':
|
||||
return 'Retake'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Submissions History (Top) -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div v-if="submissions.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Upload class="h-10 w-10 mb-2 opacity-50" />
|
||||
<p class="text-sm">No submissions yet. Submit your work below.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<SubmissionCard
|
||||
v-for="submission in submissions"
|
||||
:key="submission.id"
|
||||
:submission="submission"
|
||||
@view="handleView"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Work Form (Bottom) -->
|
||||
<div class="flex-shrink-0 border-t bg-background p-2">
|
||||
<div class="space-y-2">
|
||||
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<Upload class="h-6 w-6 mx-auto mb-1 text-muted-foreground" />
|
||||
<p class="text-xs font-medium mb-1">Submit Work</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="fileInput?.click()"
|
||||
:disabled="uploading"
|
||||
class="h-7 text-xs"
|
||||
>
|
||||
<Upload class="h-3 w-3 mr-1" />
|
||||
{{ uploading ? 'Uploading...' : 'Choose File' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
v-model="submissionNotes"
|
||||
placeholder="Add notes about this submission..."
|
||||
rows="2"
|
||||
class="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Viewer Dialog -->
|
||||
<Dialog v-model:open="viewerOpen">
|
||||
<DialogContent class="max-w-6xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{ selectedSubmission?.file_name }} (v{{ selectedSubmission?.version_number }})
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Submission preview and details
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex items-center justify-center overflow-auto">
|
||||
<!-- Video Player -->
|
||||
<video
|
||||
v-if="selectedSubmission?.stream_url && mediaBlobUrl"
|
||||
:src="mediaBlobUrl"
|
||||
controls
|
||||
class="max-w-full max-h-[70vh]"
|
||||
/>
|
||||
<!-- Image Viewer -->
|
||||
<img
|
||||
v-else-if="selectedSubmission?.thumbnail_url && mediaBlobUrl"
|
||||
:src="mediaBlobUrl"
|
||||
:alt="selectedSubmission.file_name"
|
||||
class="max-w-full max-h-[70vh] object-contain"
|
||||
/>
|
||||
<!-- Fallback for other file types -->
|
||||
<div v-else class="text-center p-8">
|
||||
<p class="text-muted-foreground mb-4">Preview not available for this file type</p>
|
||||
<Button @click="handleDownload(selectedSubmission)" variant="outline">
|
||||
Download File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedSubmission?.notes" class="mt-4 p-4 bg-muted rounded-lg">
|
||||
<p class="text-sm font-semibold mb-1">Submission Notes:</p>
|
||||
<p class="text-sm whitespace-pre-wrap">{{ selectedSubmission.notes }}</p>
|
||||
</div>
|
||||
<div v-if="selectedSubmission?.latest_review" class="mt-4 p-4 border rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-semibold">Review</p>
|
||||
<Badge :variant="selectedSubmission.latest_review.decision === 'approved' ? 'default' : 'destructive'">
|
||||
{{ selectedSubmission.latest_review.decision }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-1">
|
||||
By {{ selectedSubmission.latest_review.reviewer_first_name }} {{ selectedSubmission.latest_review.reviewer_last_name }}
|
||||
</p>
|
||||
<p v-if="selectedSubmission.latest_review.feedback" class="text-sm whitespace-pre-wrap">
|
||||
{{ selectedSubmission.latest_review.feedback }}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import SubmissionCard from './SubmissionCard.vue'
|
||||
import { taskService, type Submission } from '@/services/task'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
submissions: Submission[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submissionsUpdated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const uploading = ref(false)
|
||||
const submissionNotes = ref('')
|
||||
const viewerOpen = ref(false)
|
||||
const selectedSubmission = ref<Submission | null>(null)
|
||||
const mediaBlobUrl = ref<string | null>(null)
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
await taskService.submitWork(
|
||||
props.taskId,
|
||||
file,
|
||||
submissionNotes.value || undefined
|
||||
)
|
||||
submissionNotes.value = ''
|
||||
emit('submissionsUpdated')
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Work submitted successfully'
|
||||
})
|
||||
// Reset input
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting work:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to submit work',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleView(submission: Submission) {
|
||||
selectedSubmission.value = submission
|
||||
viewerOpen.value = true
|
||||
|
||||
// Load media for viewer
|
||||
if (submission.thumbnail_url || submission.stream_url) {
|
||||
await loadMediaForViewer(submission)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMediaForViewer(submission: Submission) {
|
||||
try {
|
||||
if (submission.stream_url) {
|
||||
// Load video
|
||||
const response = await apiClient.get(submission.stream_url, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
if (mediaBlobUrl.value) {
|
||||
URL.revokeObjectURL(mediaBlobUrl.value)
|
||||
}
|
||||
mediaBlobUrl.value = URL.createObjectURL(response.data)
|
||||
} else if (submission.download_url) {
|
||||
// Load image
|
||||
const response = await apiClient.get(submission.download_url, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
if (mediaBlobUrl.value) {
|
||||
URL.revokeObjectURL(mediaBlobUrl.value)
|
||||
}
|
||||
mediaBlobUrl.value = URL.createObjectURL(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load media:', error)
|
||||
mediaBlobUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function getFileUrl(url: string | null | undefined) {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http')) return url
|
||||
// Remove any leading slashes and backend prefix
|
||||
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
return `http://localhost:8000/${cleanUrl}`
|
||||
}
|
||||
|
||||
async function handleDownload(submission: Submission | null) {
|
||||
if (!submission?.download_url) return
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(submission.download_url, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = submission.file_name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,480 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Filters Row -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- My Tasks Quick Filter -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="{'bg-primary text-primary-foreground': myTasksFilter}"
|
||||
@click="toggleMyTasksFilter"
|
||||
class="h-8"
|
||||
>
|
||||
<User class="mr-2 h-4 w-4" />
|
||||
My Tasks
|
||||
</Button>
|
||||
|
||||
<!-- Context Filter Toggle -->
|
||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{'bg-muted':contextFilter === 'all'}"
|
||||
@click="$emit('update:context-filter', 'all')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{'bg-muted':contextFilter === 'shots'}"
|
||||
@click="$emit('update:context-filter', 'shots')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<Film class="h-4 w-4 mr-1" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{'bg-muted': contextFilter === 'assets'}"
|
||||
@click="$emit('update:context-filter', 'assets')"
|
||||
class="h-7 px-2"
|
||||
>
|
||||
<Package class="h-4 w-4 mr-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Status Filter -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<ListFilter class="mr-2 h-4 w-4" />
|
||||
Status
|
||||
<Badge
|
||||
v-if="statusFilter.length > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ statusFilter.length }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search status..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No status found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
@select="toggleStatusFilter(status.value)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
statusFilter.includes(status.value)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ status.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Task Type Filter -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Tag class="mr-2 h-4 w-4" />
|
||||
Type
|
||||
<Badge
|
||||
v-if="typeFilter.length > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ typeFilter.length }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search type..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No type found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="type in taskTypes"
|
||||
:key="type"
|
||||
:value="type"
|
||||
@select="toggleTypeFilter(type)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
typeFilter.includes(type)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span class="capitalize">{{ type.replace(/_/g, ' ') }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Episode Filter -->
|
||||
<Popover v-if="episodes.length > 0">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<Film class="mr-2 h-4 w-4" />
|
||||
Episode
|
||||
<Badge
|
||||
v-if="episodeFilter !== null"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search episode..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No episode found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="all"
|
||||
@select="$emit('update:episode-filter', null)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
episodeFilter === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>All Episodes</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
v-for="episode in episodes"
|
||||
:key="episode.id"
|
||||
:value="episode.id.toString()"
|
||||
@select="$emit('update:episode-filter', episode.id)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
episodeFilter === episode.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ episode.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Assignee Filter -->
|
||||
<Popover v-if="assignees.length > 0">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||
<User class="mr-2 h-4 w-4" />
|
||||
Assignee
|
||||
<Badge
|
||||
v-if="assigneeFilter.length > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ assigneeFilter.length }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search assignee..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No assignee found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="assignee in assignees"
|
||||
:key="assignee.id"
|
||||
:value="assignee.id.toString()"
|
||||
@select="toggleAssigneeFilter(assignee.id)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
assigneeFilter.includes(assignee.id)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ assignee.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Column Visibility -->
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="ml-auto h-8 border-dashed">
|
||||
<Settings2 class="mr-2 h-4 w-4" />
|
||||
View
|
||||
<Badge
|
||||
v-if="hiddenColumnsCount > 0"
|
||||
variant="secondary"
|
||||
class="ml-2 rounded-sm px-1 font-normal"
|
||||
>
|
||||
{{ hiddenColumnsCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="end">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="column in allColumns"
|
||||
:key="column.id"
|
||||
:value="column.id"
|
||||
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
columnVisibility[column.id] !== false
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
]"
|
||||
>
|
||||
<Check class="h-4 w-4" />
|
||||
</div>
|
||||
<span>{{ column.label }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- Detail Panel Enable/Disable Toggle Button -->
|
||||
<Button
|
||||
@click="$emit('toggle-detail-panel')"
|
||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
:class="[
|
||||
'h-8 w-8 p-0',
|
||||
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
|
||||
]"
|
||||
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
|
||||
>
|
||||
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
|
||||
<PanelRightOpen v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
:model-value="search"
|
||||
@update:model-value="debouncedSearch"
|
||||
placeholder="Search tasks..."
|
||||
class="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<Button
|
||||
v-if="hasFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Reset
|
||||
<X class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Search, ListFilter, Tag, Film, Package, User, Settings2, Check, X, PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import type { VisibilityState } from '@tanstack/vue-table'
|
||||
import type { Episode } from '@/services/episode'
|
||||
|
||||
interface Props {
|
||||
statusFilter: string[]
|
||||
typeFilter: string[]
|
||||
episodeFilter: number | null
|
||||
assigneeFilter: number[]
|
||||
contextFilter: 'all' | 'shots' | 'assets'
|
||||
search: string
|
||||
columnVisibility: VisibilityState
|
||||
episodes: Episode[]
|
||||
assignees: Array<{ id: number; name: string }>
|
||||
taskTypes: string[]
|
||||
myTasksFilter: boolean
|
||||
currentUserId: number | null
|
||||
isDetailPanelEnabled: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:status-filter': [value: string[]]
|
||||
'update:type-filter': [value: string[]]
|
||||
'update:episode-filter': [value: number | null]
|
||||
'update:assignee-filter': [value: number[]]
|
||||
'update:context-filter': [value: 'all' | 'shots' | 'assets']
|
||||
'update:search': [value: string]
|
||||
'update:column-visibility': [value: VisibilityState]
|
||||
'update:my-tasks-filter': [value: boolean]
|
||||
'toggle-detail-panel': []
|
||||
}>()
|
||||
|
||||
// Status options
|
||||
const statusOptions = [
|
||||
{ value: 'not_started', label: 'Not Started' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'submitted', label: 'Submitted' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'retake', label: 'Retake' },
|
||||
]
|
||||
|
||||
// Column definitions
|
||||
const allColumns = [
|
||||
{ id: 'name', label: 'Task Name' },
|
||||
{ id: 'task_type', label: 'Type' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
{ id: 'context', label: 'Context' },
|
||||
{ id: 'shot_asset', label: 'Shot/Asset' },
|
||||
{ id: 'episode_name', label: 'Episode' },
|
||||
{ id: 'assigned_user_name', label: 'Assignee' },
|
||||
{ id: 'deadline', label: 'Deadline' },
|
||||
{ id: 'created_at', label: 'Created' },
|
||||
{ id: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
// Computed
|
||||
const hasFilters = computed(() => {
|
||||
return (
|
||||
props.statusFilter.length > 0 ||
|
||||
props.typeFilter.length > 0 ||
|
||||
props.episodeFilter !== null ||
|
||||
props.assigneeFilter.length > 0 ||
|
||||
props.contextFilter !== 'all' ||
|
||||
props.search !== '' ||
|
||||
props.myTasksFilter
|
||||
)
|
||||
})
|
||||
|
||||
const hiddenColumnsCount = computed(() => {
|
||||
return allColumns.filter(col => props.columnVisibility[col.id] === false).length
|
||||
})
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const debouncedSearch = (value: string | number) => {
|
||||
const searchValue = typeof value === 'string' ? value : String(value)
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
emit('update:search', searchValue)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Methods
|
||||
const toggleStatusFilter = (status: string) => {
|
||||
const newFilter = props.statusFilter.includes(status)
|
||||
? props.statusFilter.filter((s) => s !== status)
|
||||
: [...props.statusFilter, status]
|
||||
emit('update:status-filter', newFilter)
|
||||
}
|
||||
|
||||
const toggleTypeFilter = (type: string) => {
|
||||
const newFilter = props.typeFilter.includes(type)
|
||||
? props.typeFilter.filter((t) => t !== type)
|
||||
: [...props.typeFilter, type]
|
||||
emit('update:type-filter', newFilter)
|
||||
}
|
||||
|
||||
const toggleAssigneeFilter = (assigneeId: number) => {
|
||||
const newFilter = props.assigneeFilter.includes(assigneeId)
|
||||
? props.assigneeFilter.filter((id) => id !== assigneeId)
|
||||
: [...props.assigneeFilter, assigneeId]
|
||||
emit('update:assignee-filter', newFilter)
|
||||
}
|
||||
|
||||
const toggleColumn = (columnId: string, value: any) => {
|
||||
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
|
||||
emit('update:column-visibility', newVisibility)
|
||||
}
|
||||
|
||||
const toggleMyTasksFilter = () => {
|
||||
const newValue = !props.myTasksFilter
|
||||
emit('update:my-tasks-filter', newValue)
|
||||
|
||||
// When enabling My Tasks, set the assignee filter to current user
|
||||
if (newValue && props.currentUserId) {
|
||||
emit('update:assignee-filter', [props.currentUserId])
|
||||
} else if (!newValue) {
|
||||
// When disabling, clear the assignee filter
|
||||
emit('update:assignee-filter', [])
|
||||
}
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
emit('update:status-filter', [])
|
||||
emit('update:type-filter', [])
|
||||
emit('update:episode-filter', null)
|
||||
emit('update:assignee-filter', [])
|
||||
emit('update:context-filter', 'all')
|
||||
emit('update:search', '')
|
||||
emit('update:my-tasks-filter', false)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
|
||||
header.column.id === 'select' ? 'w-12' : '',
|
||||
]"
|
||||
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<TableRow
|
||||
v-for="(row, index) in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
:data-task-id="row.original.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
:class="[
|
||||
'cursor-pointer hover:bg-muted/50 select-none',
|
||||
row.getIsSelected() ? 'bg-muted/50' : ''
|
||||
]"
|
||||
@click="handleRowClick(row.original, $event, index)"
|
||||
@dblclick="handleRowDoubleClick(row.original)"
|
||||
@contextmenu="handleContextMenu($event, index)"
|
||||
>
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center">
|
||||
<div class="flex flex-col items-center justify-center gap-2">
|
||||
<ListTodo class="h-8 w-8 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No tasks found
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/vue-table'
|
||||
import { ListTodo } from 'lucide-vue-next'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { createColumns } from './columns'
|
||||
import { type Task } from '@/services/task'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
|
||||
// Props interface
|
||||
interface Props {
|
||||
tasks: Task[]
|
||||
columnVisibility: VisibilityState
|
||||
projectId: number
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits interface
|
||||
interface Emits {
|
||||
(e: 'row-click', task: Task): void
|
||||
(e: 'row-double-click', task: Task): void
|
||||
(e: 'context-menu', event: MouseEvent, tasks: Task[]): void
|
||||
(e: 'selection-change', taskIds: number[]): void
|
||||
(e: 'update:column-visibility', visibility: VisibilityState): void
|
||||
(e: 'bulk-status-change', taskIds: number[], newStatus: TaskStatus): void
|
||||
(e: 'status-updated', taskId: number, newStatus: TaskStatus): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Internal state
|
||||
const sorting = ref<SortingState>([{ id: 'created_at', desc: true }])
|
||||
const rowSelection = ref<RowSelectionState>({})
|
||||
const lastClickedIndex = ref<number | null>(null)
|
||||
|
||||
// Bulk status change handler
|
||||
const handleBulkStatusChange = async (newStatus: TaskStatus) => {
|
||||
const selectedTasks = getSelectedTasks()
|
||||
if (selectedTasks.length === 0) return
|
||||
|
||||
// Emit event for parent to handle
|
||||
emit('bulk-status-change', selectedTasks.map(t => t.id), newStatus)
|
||||
}
|
||||
|
||||
// Status update handler
|
||||
const handleStatusUpdated = (taskId: number, newStatus: TaskStatus) => {
|
||||
emit('status-updated', taskId, newStatus)
|
||||
}
|
||||
|
||||
// Columns with callbacks
|
||||
const columns = createColumns({
|
||||
onBulkStatusChange: handleBulkStatusChange,
|
||||
onStatusUpdated: handleStatusUpdated,
|
||||
getSelectedCount: () => Object.keys(rowSelection.value).filter(key => rowSelection.value[key]).length,
|
||||
})
|
||||
|
||||
// TanStack Table configuration
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
return props.tasks
|
||||
},
|
||||
get columns() {
|
||||
return columns
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
getRowId: (row) => String(row.id),
|
||||
onSortingChange: (updaterOrValue) => {
|
||||
sorting.value =
|
||||
typeof updaterOrValue === 'function' ? updaterOrValue(sorting.value) : updaterOrValue
|
||||
},
|
||||
onColumnVisibilityChange: (updaterOrValue) => {
|
||||
const newVisibility =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(props.columnVisibility)
|
||||
: updaterOrValue
|
||||
emit('update:column-visibility', newVisibility)
|
||||
},
|
||||
onRowSelectionChange: (updaterOrValue) => {
|
||||
rowSelection.value =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(rowSelection.value)
|
||||
: updaterOrValue
|
||||
},
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value
|
||||
},
|
||||
get columnVisibility() {
|
||||
return props.columnVisibility
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Watch rowSelection changes and emit selection-change events
|
||||
watch(
|
||||
rowSelection,
|
||||
(newSelection) => {
|
||||
const selectedIds = Object.keys(newSelection)
|
||||
.filter(key => newSelection[key])
|
||||
.map(key => parseInt(key))
|
||||
emit('selection-change', selectedIds)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Helper function to compute selected tasks from selection state
|
||||
const getSelectedTasks = (): Task[] => {
|
||||
const selectedIds = Object.keys(rowSelection.value).filter(key => rowSelection.value[key])
|
||||
return props.tasks.filter(task => selectedIds.includes(String(task.id)))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleRowClick = (task: Task, event: MouseEvent, index: number) => {
|
||||
// If double-click handler will handle it, skip selection logic
|
||||
if (event.detail === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = String(task.id)
|
||||
|
||||
if (event.shiftKey && lastClickedIndex.value !== null) {
|
||||
// Shift+Click: Range selection
|
||||
const start = Math.min(lastClickedIndex.value, index)
|
||||
const end = Math.max(lastClickedIndex.value, index)
|
||||
const newSelection: Record<string, boolean> = {}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const id = String(props.tasks[i].id)
|
||||
newSelection[id] = true
|
||||
}
|
||||
|
||||
// Update rowSelection - create completely new object to trigger reactivity
|
||||
rowSelection.value = newSelection
|
||||
console.log('Shift-click selection updated:', rowSelection.value)
|
||||
lastClickedIndex.value = index
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Ctrl/Cmd+Click: Toggle selection
|
||||
const newSelection = { ...rowSelection.value }
|
||||
if (newSelection[taskId]) {
|
||||
delete newSelection[taskId]
|
||||
} else {
|
||||
newSelection[taskId] = true
|
||||
}
|
||||
rowSelection.value = newSelection
|
||||
lastClickedIndex.value = index
|
||||
} else {
|
||||
// Regular click: Select only this row
|
||||
rowSelection.value = { [taskId]: true }
|
||||
lastClickedIndex.value = index
|
||||
}
|
||||
|
||||
// Emit row-click event (selection-change is emitted by watcher)
|
||||
emit('row-click', task)
|
||||
}
|
||||
|
||||
const handleRowDoubleClick = (task: Task) => {
|
||||
emit('row-double-click', task)
|
||||
}
|
||||
|
||||
const handleContextMenu = (event: MouseEvent, index: number) => {
|
||||
event.preventDefault()
|
||||
|
||||
// Prevent context menu on empty table areas
|
||||
if (props.tasks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const rightClickedTask = props.tasks[index]
|
||||
if (!rightClickedTask) return
|
||||
|
||||
const taskId = String(rightClickedTask.id)
|
||||
|
||||
// If right-clicked row is not selected, add it to selection
|
||||
// If it's already selected, keep current selection
|
||||
if (!rowSelection.value[taskId]) {
|
||||
const newSelection = { ...rowSelection.value }
|
||||
newSelection[taskId] = true
|
||||
rowSelection.value = newSelection
|
||||
}
|
||||
|
||||
// Get currently selected tasks
|
||||
const selectedTasks = getSelectedTasks()
|
||||
|
||||
// Emit context-menu event with selected tasks
|
||||
emit('context-menu', event, selectedTasks)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,295 @@
|
||||
import { h, ref } from 'vue'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { ArrowUpDown, Film, Package, ChevronDown } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue'
|
||||
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
|
||||
import { type Task } from '@/services/task'
|
||||
import { TaskStatus } from '@/services/asset'
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
interface ColumnCallbacks {
|
||||
onBulkStatusChange?: (status: TaskStatus) => void
|
||||
onStatusUpdated?: (taskId: number, newStatus: TaskStatus) => void
|
||||
getSelectedCount?: () => number
|
||||
}
|
||||
|
||||
export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => {
|
||||
// Create ref outside column definitions so it persists across renders
|
||||
const isPopoverOpen = ref(false)
|
||||
|
||||
return [
|
||||
// Select column
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(value === true),
|
||||
ariaLabel: 'Select all',
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(value === true),
|
||||
ariaLabel: 'Select row',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
}),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Task Name', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'font-medium' }, row.getValue('name'))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'task_type',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Type', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const taskType = row.getValue('task_type') as string
|
||||
return h(
|
||||
Badge,
|
||||
{ variant: 'outline', class: 'capitalize' },
|
||||
() => taskType.replace(/_/g, ' ')
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => {
|
||||
const selectedCount = callbacks?.getSelectedCount?.() || 0
|
||||
|
||||
if (selectedCount > 0) {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Status', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
),
|
||||
h('div', { onClick: (e: Event) => e.stopPropagation() }, [
|
||||
h(Popover, {
|
||||
open: isPopoverOpen.value,
|
||||
'onUpdate:open': (value: boolean) => { isPopoverOpen.value = value }
|
||||
}, {
|
||||
default: () => [
|
||||
h(PopoverTrigger, {}, {
|
||||
default: () => h(
|
||||
Button,
|
||||
{
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
class: 'h-8 w-8 p-0',
|
||||
},
|
||||
() => h(ChevronDown, { class: 'h-4 w-4' })
|
||||
),
|
||||
}),
|
||||
h(PopoverContent, { class: 'w-48 p-2', align: 'start' }, {
|
||||
default: () => {
|
||||
|
||||
return h('div', { class: 'flex flex-col gap-1' }, [
|
||||
h('div', { class: 'px-2 py-1.5 text-sm font-semibold' }, `Change Status`),
|
||||
...Object.values(TaskStatus).map((status) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'justify-start',
|
||||
onClick: () => {
|
||||
callbacks?.onBulkStatusChange?.(status)
|
||||
isPopoverOpen.value = false
|
||||
},
|
||||
},
|
||||
() => h(TaskStatusBadge, { status, class: 'w-full' })
|
||||
)
|
||||
),
|
||||
])
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Status', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const task = row.original
|
||||
return h(EditableTaskStatus, {
|
||||
taskId: task.id,
|
||||
status: row.getValue('status') as TaskStatus,
|
||||
projectId: task.project_id,
|
||||
onStatusUpdated: (taskId: number, newStatus: TaskStatus) => {
|
||||
callbacks?.onStatusUpdated?.(taskId, newStatus)
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
header: 'Context',
|
||||
cell: ({ row }) => {
|
||||
const task = row.original
|
||||
const isShot = !!task.shot_id
|
||||
const icon = isShot ? Film : Package
|
||||
const label = isShot ? 'Shot' : 'Asset'
|
||||
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(icon, { class: 'h-4 w-4 text-muted-foreground' }),
|
||||
h('span', { class: 'text-sm text-muted-foreground' }, label),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'shot_asset',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Shot/Asset', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
)
|
||||
},
|
||||
accessorFn: (row) => row.shot_name || row.asset_name || '',
|
||||
cell: ({ row }) => {
|
||||
const task = row.original
|
||||
const name = task.shot_name || task.asset_name || '-'
|
||||
return h('div', { class: 'font-medium' }, name)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'episode_name',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Episode', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const episodeName = row.getValue('episode_name') as string | undefined
|
||||
return h('div', { class: 'text-sm' }, episodeName || '-')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'assigned_user_name',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Assignee', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const assigneeName = row.getValue('assigned_user_name') as string | undefined
|
||||
return h('div', { class: 'text-sm' }, assigneeName || 'Unassigned')
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'deadline',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Deadline', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const deadline = row.getValue('deadline') as string | undefined
|
||||
if (!deadline) return h('div', { class: 'text-sm text-muted-foreground' }, '-')
|
||||
|
||||
const date = new Date(deadline)
|
||||
const now = new Date()
|
||||
const isOverdue = date < now
|
||||
const isUrgent = date.getTime() - now.getTime() < 3 * 24 * 60 * 60 * 1000 // 3 days
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: [
|
||||
'text-sm',
|
||||
isOverdue ? 'text-destructive font-medium' : isUrgent ? 'text-orange-600 font-medium' : '',
|
||||
],
|
||||
},
|
||||
formatDate(deadline)
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
variant: 'ghost',
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc'),
|
||||
},
|
||||
() => ['Created', h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })]
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-sm text-muted-foreground' }, formatDate(row.getValue('created_at')))
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
||||
import { AlertDialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { AlertVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { alertVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: AlertVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user