Fix broken joinedload and import path in reviews/activities endpoints
joinedload("reviewer") used a string instead of a class-bound
attribute (rejected by SQLAlchemy 2.x), and models.submission was the
wrong import path for Submission. Both endpoints 500'd whenever
actually called; surfaced while wiring real dashboard/activity data
into the frontend.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,7 @@ async def get_pending_reviews(
|
||||
query = db.query(Submission).options(
|
||||
joinedload(Submission.user),
|
||||
joinedload(Submission.task).joinedload(Task.project),
|
||||
joinedload(Submission.reviews).joinedload("reviewer")
|
||||
joinedload(Submission.reviews).joinedload(Review.reviewer)
|
||||
).join(Task).filter(
|
||||
Submission.deleted_at.is_(None),
|
||||
Task.deleted_at.is_(None)
|
||||
|
||||
@@ -302,10 +302,9 @@ class ActivityService:
|
||||
"""Get activities excluding those related to deleted records."""
|
||||
from sqlalchemy import and_, or_, desc
|
||||
from datetime import datetime, timedelta
|
||||
from models.task import Task
|
||||
from models.task import Task, Submission
|
||||
from models.shot import Shot
|
||||
from models.asset import Asset
|
||||
from models.submission import Submission
|
||||
|
||||
query = db.query(Activity)
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<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>
|
||||
@@ -1,256 +0,0 @@
|
||||
<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>
|
||||
@@ -1,123 +0,0 @@
|
||||
<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>
|
||||
@@ -1,281 +0,0 @@
|
||||
<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>
|
||||
@@ -1,159 +0,0 @@
|
||||
<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="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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } 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'
|
||||
import { useColumnVisibilityStore } from '@/stores/columnVisibility'
|
||||
|
||||
interface Props {
|
||||
allTaskTypes: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:columnVisibility': [visibility: VisibilityState]
|
||||
}>()
|
||||
|
||||
// Use global store
|
||||
const columnVisibilityStore = useColumnVisibilityStore()
|
||||
|
||||
// Local reactive state for checkbox visibility
|
||||
const localVisibility = ref<Record<string, boolean>>({})
|
||||
|
||||
// Watch for store changes and update local state
|
||||
watch(
|
||||
() => columnVisibilityStore.columnVisibility,
|
||||
(newVal) => {
|
||||
localVisibility.value = { ...newVal }
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// Initialize store on mount
|
||||
onMounted(() => {
|
||||
columnVisibilityStore.initialize()
|
||||
})
|
||||
|
||||
const selectedColumn = ref('toggle')
|
||||
|
||||
const handleColumnToggle = (value: string) => {
|
||||
// Reset selection after interaction
|
||||
selectedColumn.value = 'toggle'
|
||||
}
|
||||
|
||||
const isColumnVisible = (columnId: string): boolean => {
|
||||
// If not in visibility state, column is visible by default
|
||||
return localVisibility.value[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) => {
|
||||
// Always use global store
|
||||
columnVisibilityStore.updateColumn(column, checked)
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||
}
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div class="container mx-auto py-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Global Settings</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Manage system-wide configuration settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlobalSettingsPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GlobalSettingsPanel from '@/components/settings/GlobalSettingsPanel.vue'
|
||||
</script>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-4xl font-bold text-foreground mb-4">
|
||||
VFX Project Management System
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-lg">
|
||||
Welcome to the VFX Project Management System. This application will help you manage
|
||||
animation and VFX production workflows.
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Get Started
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Home view component
|
||||
</script>
|
||||
Reference in New Issue
Block a user