Files
LinkDesk/frontend/src/components/task/TaskTableToolbar.vue
T
indigo e628c99498 Sticky table headers, layout parity, and reuse My Tasks on the shared table/toolbar
Shot/Asset/Task data tables now keep their header row pinned while scrolling
(both the locked two-pane and single-table modes), matching a common data-
table expectation that was missing everywhere.

Brings Asset and Task browsers to full structural parity with Shot's bounded-
height layout (fixed toolbar, internally-scrolling table) instead of their
previous page-scroll model with a sticky-positioned toolbar. AssetsDataTable
gained the synced frozen/movable-pane scrolling it was missing entirely;
TasksDataTable and both browsers/parent views got the same container model.

The global My Tasks page (/tasks) now reuses TaskTableToolbar and
TasksDataTable instead of its own ad hoc filter selects and TaskList,
including the detail panel, bulk status/assignment context menu, and sticky
header for free. Since it spans multiple projects, TaskTableToolbar gained
an optional Project filter (only rendered when a project list is passed in,
so the project-scoped Tasks page is unaffected); episode/assignee filters
populate once a specific project is chosen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 10:15:04 +08:00

406 lines
13 KiB
Vue

<template>
<div class="flex flex-col gap-4">
<!-- Filters Row -->
<div class="flex flex-wrap gap-2">
<!-- My Tasks Quick Filter -->
<Button
variant="outline"
size="sm"
:class="{'bg-primary text-primary-foreground': myTasksFilter}"
@click="toggleMyTasksFilter"
class="h-8"
>
<User class="mr-2 h-4 w-4" />
My Tasks
</Button>
<!-- Context Filter Toggle -->
<SegmentedToggle
:options="contextFilterOptions"
:model-value="contextFilter"
@update:model-value="$emit('update:context-filter', $event as 'all' | 'shots' | 'assets')"
/>
<!-- Status Filter -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Status
<Badge
v-if="statusFilter.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ statusFilter.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<CheckableCommandItem
v-for="status in statusOptions"
:key="status.value"
:value="status.value"
:model-value="statusFilter.includes(status.value)"
@update:model-value="toggleStatusFilter(status.value)"
>
<span>{{ status.label }}</span>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Task Type Filter -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Tag class="mr-2 h-4 w-4" />
Type
<Badge
v-if="typeFilter.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ typeFilter.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search type..." />
<CommandList>
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
<CheckableCommandItem
v-for="type in taskTypes"
:key="type"
:value="type"
:model-value="typeFilter.includes(type)"
@update:model-value="toggleTypeFilter(type)"
>
<span class="capitalize">{{ type.replace(/_/g, ' ') }}</span>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Project Filter (only shown when a cross-project task list is passed in, e.g. My Tasks) -->
<Popover v-if="projects && projects.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<FolderOpen class="mr-2 h-4 w-4" />
Project
<Badge
v-if="projectFilter !== null"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
1
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search project..." />
<CommandList>
<CommandEmpty>No project found.</CommandEmpty>
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="projectFilter === null"
@update:model-value="$emit('update:project-filter', null)"
>
<span>All Projects</span>
</CheckableCommandItem>
<CheckableCommandItem
v-for="project in projects"
:key="project.id"
:value="project.id.toString()"
:model-value="projectFilter === project.id"
@update:model-value="$emit('update:project-filter', project.id)"
>
<span>{{ project.name }}</span>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Episode Filter -->
<Popover v-if="episodes.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Film class="mr-2 h-4 w-4" />
Episode
<Badge
v-if="episodeFilter !== null"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
1
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search episode..." />
<CommandList>
<CommandEmpty>No episode found.</CommandEmpty>
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="episodeFilter === null"
@update:model-value="$emit('update:episode-filter', null)"
>
<span>All Episodes</span>
</CheckableCommandItem>
<CheckableCommandItem
v-for="episode in episodes"
:key="episode.id"
:value="episode.id.toString()"
:model-value="episodeFilter === episode.id"
@update:model-value="$emit('update:episode-filter', episode.id)"
>
<span>{{ episode.name }}</span>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Assignee Filter -->
<Popover v-if="assignees.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<User class="mr-2 h-4 w-4" />
Assignee
<Badge
v-if="assigneeFilter.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ assigneeFilter.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search assignee..." />
<CommandList>
<CommandEmpty>No assignee found.</CommandEmpty>
<CommandGroup>
<CheckableCommandItem
v-for="assignee in assignees"
:key="assignee.id"
:value="assignee.id.toString()"
:model-value="assigneeFilter.includes(assignee.id)"
@update:model-value="toggleAssigneeFilter(assignee.id)"
>
<span>{{ assignee.name }}</span>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Column Visibility -->
<div class="ml-auto">
<ColumnToggleList
:columns="allColumns"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
</div>
<!-- Detail Panel Enable/Disable Toggle Button -->
<DetailPanelToggleButton
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Search -->
<div class="relative flex-1">
<Search class="absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
<Input
:model-value="search"
@update:model-value="debouncedSearch"
placeholder="Search tasks..."
class="h-8 pl-8"
/>
</div>
<!-- Clear Filters -->
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User, FolderOpen } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
CheckableCommandItem,
} from '@/components/ui/command'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Episode } from '@/services/episode'
import type { Project } from '@/services/project'
interface Props {
statusFilter: string[]
typeFilter: string[]
episodeFilter: number | null
assigneeFilter: number[]
contextFilter: 'all' | 'shots' | 'assets'
search: string
columnVisibility: VisibilityState
episodes: Episode[]
assignees: Array<{ id: number; name: string }>
taskTypes: string[]
myTasksFilter: boolean
currentUserId: number | null
isDetailPanelEnabled: boolean
// Cross-project task lists (e.g. My Tasks) pass a project list + filter; the
// project-scoped Tasks page omits these and the filter stays hidden.
projects?: Project[]
projectFilter?: number | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:status-filter': [value: string[]]
'update:type-filter': [value: string[]]
'update:episode-filter': [value: number | null]
'update:assignee-filter': [value: number[]]
'update:context-filter': [value: 'all' | 'shots' | 'assets']
'update:search': [value: string]
'update:column-visibility': [value: VisibilityState]
'update:my-tasks-filter': [value: boolean]
'update:project-filter': [value: number | null]
'toggle-detail-panel': []
}>()
// Status options
const statusOptions = [
{ value: 'not_started', label: 'Not Started' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'submitted', label: 'Submitted' },
{ value: 'approved', label: 'Approved' },
{ value: 'retake', label: 'Retake' },
]
const contextFilterOptions = [
{ value: 'all', label: 'All' },
{ value: 'shots', icon: Film },
{ value: 'assets', icon: Package },
]
// Column definitions
const allColumns = [
{ id: 'name', label: 'Task Name' },
{ id: 'task_type', label: 'Type' },
{ id: 'status', label: 'Status' },
{ id: 'context', label: 'Context' },
{ id: 'shot_asset', label: 'Shot/Asset' },
{ id: 'episode_name', label: 'Episode' },
{ id: 'assigned_user_name', label: 'Assignee' },
{ id: 'deadline', label: 'Deadline' },
{ id: 'created_at', label: 'Created' },
{ id: 'actions', label: 'Actions' },
]
// Computed
const hasFilters = computed(() => {
return (
props.statusFilter.length > 0 ||
props.typeFilter.length > 0 ||
props.episodeFilter !== null ||
props.assigneeFilter.length > 0 ||
props.contextFilter !== 'all' ||
props.search !== '' ||
props.myTasksFilter ||
(props.projectFilter ?? null) !== null
)
})
// Debounced search
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleStatusFilter = (status: string) => {
const newFilter = props.statusFilter.includes(status)
? props.statusFilter.filter((s) => s !== status)
: [...props.statusFilter, status]
emit('update:status-filter', newFilter)
}
const toggleTypeFilter = (type: string) => {
const newFilter = props.typeFilter.includes(type)
? props.typeFilter.filter((t) => t !== type)
: [...props.typeFilter, type]
emit('update:type-filter', newFilter)
}
const toggleAssigneeFilter = (assigneeId: number) => {
const newFilter = props.assigneeFilter.includes(assigneeId)
? props.assigneeFilter.filter((id) => id !== assigneeId)
: [...props.assigneeFilter, assigneeId]
emit('update:assignee-filter', newFilter)
}
const toggleMyTasksFilter = () => {
const newValue = !props.myTasksFilter
emit('update:my-tasks-filter', newValue)
// When enabling My Tasks, set the assignee filter to current user
if (newValue && props.currentUserId) {
emit('update:assignee-filter', [props.currentUserId])
} else if (!newValue) {
// When disabling, clear the assignee filter
emit('update:assignee-filter', [])
}
}
const clearFilters = () => {
emit('update:status-filter', [])
emit('update:type-filter', [])
emit('update:episode-filter', null)
emit('update:assignee-filter', [])
emit('update:context-filter', 'all')
emit('update:search', '')
emit('update:my-tasks-filter', false)
if (props.projects) {
emit('update:project-filter', null)
}
}
</script>