Init Repo
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user