26807984ee
Extracts duplication that built up as shot/asset/task features reached parity: CheckableCommandItem/ColumnToggleList replace 14+ hand-rolled checkbox-list blocks, shared toolbar pieces (debounced search, detail-panel toggle, clear-filters, segmented view/context toggle) replace copy-pasted markup in the three table toolbars, icon-only buttons standardize on the icon-sm size, TaskBulkActionsMenu's Assign To submenu matches Set Status, and a shared DetailPanelOverlay/Header/Loading/Error shell backs all three detail panels (adding a previously-missing error state to the task panel).
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import type { ColumnDef } from '@tanstack/vue-table'
|
|
import { h, ref } from 'vue'
|
|
import { Camera, MoreHorizontal, Edit, ListTodo, Trash2, ArrowUpDown, ArrowUp, ArrowDown, ChevronDown } from 'lucide-vue-next'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
|
|
import EditableTaskStatus from './EditableTaskStatus.vue'
|
|
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
|
import { type Shot, ShotStatus, TaskStatus } from '@/services/shot'
|
|
|
|
// Helper function to get the appropriate sort icon
|
|
const getSortIcon = (sortDirection: false | 'asc' | 'desc') => {
|
|
if (sortDirection === 'asc') {
|
|
return h(ArrowDown, { class: 'ml-2 h-4 w-4' })
|
|
} else if (sortDirection === 'desc') {
|
|
return h(ArrowUp, { class: 'ml-2 h-4 w-4' })
|
|
} else {
|
|
return h(ArrowUpDown, { class: 'ml-2 h-4 w-4' })
|
|
}
|
|
}
|
|
|
|
export interface ShotColumnMeta {
|
|
projectId: number
|
|
episodes: Array<{ id: number; name: string }>
|
|
onEdit: (shot: Shot) => void
|
|
onDelete: (shot: Shot) => void
|
|
onViewTasks: (shot: Shot) => void
|
|
onTaskStatusUpdated: (shotId: number, taskType: string, newStatus: TaskStatus) => void
|
|
onTaskAssignmentUpdated?: (shotId: number, taskType: string, userId: number | null) => void
|
|
onBulkTaskStatusChange?: (taskType: string, status: TaskStatus) => void
|
|
getSelectedCount?: () => number
|
|
getAllStatusOptions?: () => Array<{ id: string; name: string; color?: string; is_system?: boolean }>
|
|
}
|
|
|
|
export const createShotColumns = (
|
|
allTaskTypes: string[],
|
|
meta: ShotColumnMeta
|
|
): ColumnDef<Shot>[] => {
|
|
const columns: ColumnDef<Shot>[] = [
|
|
// Select column
|
|
{
|
|
id: 'select',
|
|
header: ({ table }) =>
|
|
h(Checkbox, {
|
|
modelValue: table.getIsAllPageRowsSelected(),
|
|
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(value === true),
|
|
ariaLabel: 'Select all',
|
|
}),
|
|
cell: ({ row }) =>
|
|
h(Checkbox, {
|
|
modelValue: row.getIsSelected(),
|
|
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(value === true),
|
|
ariaLabel: 'Select row',
|
|
onClick: (e: Event) => e.stopPropagation(),
|
|
}),
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
},
|
|
// Thumbnail column
|
|
{
|
|
id: 'thumbnail',
|
|
header: 'Thumbnail',
|
|
cell: () => {
|
|
return h('div', { class: 'w-20 h-11 bg-gray-500/20 flex items-center justify-center' }, [
|
|
h(Camera, { class: 'h-6 w-6 text-muted-foreground text-gray-900' }),
|
|
])
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
// Shot Name column
|
|
{
|
|
accessorKey: 'name',
|
|
header: ({ column }) => {
|
|
return h(
|
|
// Button,
|
|
// {
|
|
// variant: 'ghost',
|
|
// onClick: () => column.toggleSorting(column.getIsSorted() === 'desc'),
|
|
// },
|
|
// () => ['Shot Name', getSortIcon(column.getIsSorted())]
|
|
'div', {class:'flex items-center justify-center'}, ['Shot Name', getSortIcon(column.getIsSorted())]
|
|
)
|
|
},
|
|
cell: ({ row }) => {
|
|
const shot = row.original
|
|
return h('div', { class: 'flex items-center gap-2' }, [
|
|
// h(Camera, { class: 'h-4 w-4 text-muted-foreground flex-shrink-0' }),
|
|
h('span', { class: 'font-medium' }, shot.name),
|
|
])
|
|
},
|
|
},
|
|
// Episode column
|
|
{
|
|
id: 'episode',
|
|
accessorFn: (row) => {
|
|
const episode = meta.episodes.find((e) => e.id === row.episode_id)
|
|
return episode ? episode.name : `Episode ${row.episode_id}`
|
|
},
|
|
header: ({ column }) => {
|
|
return h(
|
|
'div', {class:'flex items-center justify-center'}, ['Episode', getSortIcon(column.getIsSorted())]
|
|
)
|
|
},
|
|
cell: ({ row }) => {
|
|
const shot = row.original
|
|
const episode = meta.episodes.find((e) => e.id === shot.episode_id)
|
|
const episodeName = episode ? episode.name : `Episode ${shot.episode_id}`
|
|
return h(Badge, { variant: 'outline', class: 'text-xs' }, () => episodeName)
|
|
},
|
|
},
|
|
// Frames column (frame count)
|
|
{
|
|
id: 'frames',
|
|
accessorFn: (row) => row.frame_end - row.frame_start + 1,
|
|
header: ({ column }) => {
|
|
return h(
|
|
'div', {class:'flex items-center justify-center'}, ['Frames', getSortIcon(column.getIsSorted())]
|
|
)
|
|
},
|
|
cell: ({ row }) => {
|
|
const shot = row.original
|
|
const frameCount = shot.frame_end - shot.frame_start + 1
|
|
return h('span', { class: 'text-sm font-medium' }, frameCount.toString())
|
|
},
|
|
},
|
|
// Status column
|
|
{
|
|
accessorKey: 'status',
|
|
header: ({ column }) => {
|
|
return h(
|
|
'div', {class:'flex items-center justify-center'}, ['Status', getSortIcon(column.getIsSorted())]
|
|
)
|
|
},
|
|
cell: ({ row }) => {
|
|
const shot = row.original
|
|
const variant = getStatusVariant(shot.status)
|
|
const label = formatStatus(shot.status)
|
|
return h(Badge, { variant, class: 'text-xs' }, () => label)
|
|
},
|
|
},
|
|
]
|
|
|
|
// Add task status columns dynamically
|
|
allTaskTypes.forEach((taskType) => {
|
|
// Create ref for popover state for each task type
|
|
const isPopoverOpen = ref(false)
|
|
|
|
columns.push({
|
|
accessorKey: `task_status.${taskType}`,
|
|
id: taskType,
|
|
header: ({ column }) => {
|
|
const selectedCount = meta.getSelectedCount?.() || 0
|
|
|
|
if (selectedCount > 0) {
|
|
return h('div', { class: 'flex items-center gap-2' }, [
|
|
h(
|
|
'div',
|
|
{ class: 'flex items-center justify-center' },
|
|
[formatTaskType(taskType), getSortIcon(column.getIsSorted())]
|
|
),
|
|
h('div', { onClick: (e: Event) => e.stopPropagation() }, [
|
|
h(Popover, {
|
|
open: isPopoverOpen.value,
|
|
'onUpdate:open': (value: boolean) => { isPopoverOpen.value = value }
|
|
}, {
|
|
default: () => [
|
|
h(PopoverTrigger, {}, {
|
|
default: () => h(
|
|
Button,
|
|
{
|
|
variant: 'outline',
|
|
size: 'sm',
|
|
class: 'h-6 w-6 p-0',
|
|
},
|
|
() => h(ChevronDown, { class: 'h-3 w-3' })
|
|
),
|
|
}),
|
|
h(PopoverContent, { class: 'w-48 p-2', align: 'start' }, {
|
|
default: () => {
|
|
// Get task statuses from meta
|
|
const allStatusOptions = meta.getAllStatusOptions?.() || []
|
|
|
|
return h('div', { class: 'flex flex-col gap-1' }, [
|
|
h('div', { class: 'px-2 py-1.5 text-sm font-semibold' }, `Change ${formatTaskType(taskType)} Status`),
|
|
...allStatusOptions.map((statusOption) =>
|
|
h(
|
|
Button,
|
|
{
|
|
variant: 'ghost',
|
|
size: 'sm',
|
|
class: 'justify-start',
|
|
onClick: () => {
|
|
meta.onBulkTaskStatusChange?.(taskType, statusOption.id as TaskStatus)
|
|
isPopoverOpen.value = false
|
|
},
|
|
},
|
|
() => h(TaskStatusBadge, { status: statusOption, compact: true })
|
|
)
|
|
),
|
|
])
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
]),
|
|
])
|
|
}
|
|
|
|
return h(
|
|
'div',
|
|
{ class: 'flex items-center justify-center' },
|
|
[formatTaskType(taskType), getSortIcon(column.getIsSorted())]
|
|
)
|
|
},
|
|
cell: ({ row }) => {
|
|
const shot = row.original
|
|
const status = shot.task_status?.[taskType] || TaskStatus.NOT_STARTED
|
|
const taskId = shot.task_ids?.[taskType]
|
|
|
|
// Get assigned user ID from task_details
|
|
const taskDetail = shot.task_details?.find(detail => detail.task_type === taskType)
|
|
const assignedUserId = taskDetail?.assigned_user_id || null
|
|
|
|
return h(EditableTaskStatus, {
|
|
key: `${shot.id}-${taskType}`, // Add stable key to prevent unnecessary re-renders
|
|
shotId: shot.id,
|
|
taskType,
|
|
status,
|
|
taskId,
|
|
projectId: meta.projectId,
|
|
assignedUserId,
|
|
onStatusUpdated: (shotId: number, taskType: string, newStatus: TaskStatus) => {
|
|
meta.onTaskStatusUpdated(shotId, taskType, newStatus)
|
|
},
|
|
onAssignmentUpdated: (shotId: number, taskType: string, userId: number | null) => {
|
|
meta.onTaskAssignmentUpdated?.(shotId, taskType, userId)
|
|
},
|
|
})
|
|
},
|
|
enableSorting: true,
|
|
})
|
|
})
|
|
|
|
|
|
|
|
// Actions column
|
|
columns.push({
|
|
id: 'actions',
|
|
cell: ({ row }) => {
|
|
const shot = row.original
|
|
return h(
|
|
DropdownMenu,
|
|
{},
|
|
{
|
|
default: () => [
|
|
h(
|
|
DropdownMenuTrigger,
|
|
{
|
|
asChild: true
|
|
},
|
|
{
|
|
default: () =>
|
|
h(
|
|
Button,
|
|
{
|
|
variant: 'ghost',
|
|
size: 'icon-sm',
|
|
onMouseDown: (e: Event) => {
|
|
e.stopPropagation()
|
|
},
|
|
onClick: (e: Event) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
}
|
|
},
|
|
{
|
|
default: () => h(MoreHorizontal, { class: 'h-4 w-4' }),
|
|
}
|
|
),
|
|
}
|
|
),
|
|
h(
|
|
DropdownMenuContent,
|
|
{
|
|
align: 'end'
|
|
},
|
|
{
|
|
default: () => [
|
|
h(
|
|
DropdownMenuItem,
|
|
{
|
|
onClick: (e: Event) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
meta.onEdit(shot)
|
|
}
|
|
},
|
|
{
|
|
default: () => [
|
|
h(Edit, { class: 'h-4 w-4 mr-2' }),
|
|
'Edit Shot',
|
|
],
|
|
}
|
|
),
|
|
h(
|
|
DropdownMenuItem,
|
|
{
|
|
onClick: (e: Event) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
meta.onViewTasks(shot)
|
|
}
|
|
},
|
|
{
|
|
default: () => [
|
|
h(ListTodo, { class: 'h-4 w-4 mr-2' }),
|
|
'View Tasks',
|
|
],
|
|
}
|
|
),
|
|
h(DropdownMenuSeparator),
|
|
h(
|
|
DropdownMenuItem,
|
|
{
|
|
onClick: (e: Event) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
meta.onDelete(shot)
|
|
},
|
|
class: 'text-destructive focus:text-destructive',
|
|
},
|
|
{
|
|
default: () => [
|
|
h(Trash2, { class: 'h-4 w-4 mr-2' }),
|
|
'Delete Shot',
|
|
],
|
|
}
|
|
),
|
|
],
|
|
}
|
|
),
|
|
],
|
|
}
|
|
)
|
|
},
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
})
|
|
|
|
return columns
|
|
}
|
|
|
|
// Helper functions
|
|
function formatTaskType(taskType: string): string {
|
|
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
|
}
|
|
|
|
function formatStatus(status: ShotStatus): string {
|
|
return status
|
|
.split('_')
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ')
|
|
}
|
|
|
|
function getStatusVariant(status: ShotStatus): 'default' | 'secondary' | 'outline' {
|
|
switch (status) {
|
|
case ShotStatus.NOT_STARTED:
|
|
return 'secondary'
|
|
case ShotStatus.IN_PROGRESS:
|
|
return 'default'
|
|
case ShotStatus.ON_HOLD:
|
|
return 'outline'
|
|
case ShotStatus.COMPLETED:
|
|
return 'default'
|
|
case ShotStatus.APPROVED:
|
|
return 'default'
|
|
default:
|
|
return 'secondary'
|
|
}
|
|
}
|