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(
|
query = db.query(Submission).options(
|
||||||
joinedload(Submission.user),
|
joinedload(Submission.user),
|
||||||
joinedload(Submission.task).joinedload(Task.project),
|
joinedload(Submission.task).joinedload(Task.project),
|
||||||
joinedload(Submission.reviews).joinedload("reviewer")
|
joinedload(Submission.reviews).joinedload(Review.reviewer)
|
||||||
).join(Task).filter(
|
).join(Task).filter(
|
||||||
Submission.deleted_at.is_(None),
|
Submission.deleted_at.is_(None),
|
||||||
Task.deleted_at.is_(None)
|
Task.deleted_at.is_(None)
|
||||||
|
|||||||
@@ -302,10 +302,9 @@ class ActivityService:
|
|||||||
"""Get activities excluding those related to deleted records."""
|
"""Get activities excluding those related to deleted records."""
|
||||||
from sqlalchemy import and_, or_, desc
|
from sqlalchemy import and_, or_, desc
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from models.task import Task
|
from models.task import Task, Submission
|
||||||
from models.shot import Shot
|
from models.shot import Shot
|
||||||
from models.asset import Asset
|
from models.asset import Asset
|
||||||
from models.submission import Submission
|
|
||||||
|
|
||||||
query = db.query(Activity)
|
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