Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
+67
View File
@@ -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>
+1
View File
@@ -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>
+262
View File
@@ -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>
+395
View File
@@ -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>
+421
View File
@@ -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>
+223
View File
@@ -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>
+180
View File
@@ -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>
+300
View File
@@ -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>
+404
View File
@@ -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>
+202
View File
@@ -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>
+614
View File
@@ -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>
+101
View File
@@ -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>
+295
View File
@@ -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