e628c99498
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>
406 lines
13 KiB
Vue
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>
|