445 lines
14 KiB
Vue
445 lines
14 KiB
Vue
<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>
|