Files
LinkDesk/frontend/src/components/shot/columns.ts
T
indigo 26807984ee Phase 3: Unify controls across shot/asset/task toolbars and detail panels
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).
2026-07-18 02:15:55 +08:00

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'
}
}