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>
This commit is contained in:
2026-07-18 10:15:04 +08:00
parent 442abc3586
commit e628c99498
9 changed files with 525 additions and 163 deletions
+12 -12
View File
@@ -1,9 +1,9 @@
<template>
<div class="relative h-full">
<!-- Main Content (Full Width) -->
<div class="space-y-4">
<!-- Toolbar - Sticky -->
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<div class="relative h-full flex flex-col">
<!-- Main Content -->
<div class="flex flex-col flex-1 min-h-0">
<!-- Toolbar -->
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<AssetTableToolbar
v-if="customTaskTypesLoaded"
:view-mode="viewMode"
@@ -36,7 +36,7 @@
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
@@ -46,7 +46,7 @@
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12">
<div v-else-if="error" class="text-center py-12 px-4 sm:px-6">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
@@ -61,7 +61,7 @@
v-else-if="
filteredAssets.length === 0 && !searchQuery && !selectedCategory
"
class="text-center py-12"
class="text-center py-12 px-4 sm:px-6"
>
<Package class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No assets yet</h3>
@@ -75,7 +75,7 @@
</div>
<!-- No Results State -->
<div v-else-if="filteredAssets.length === 0" class="text-center py-12">
<div v-else-if="filteredAssets.length === 0" class="text-center py-12 px-4 sm:px-6">
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No assets found</h3>
<p class="text-muted-foreground mb-4">
@@ -85,11 +85,11 @@
</div>
<!-- Assets Grid/List -->
<div v-else>
<div v-else class="flex-1 min-h-0">
<!-- Grid View -->
<div
v-if="viewMode === 'grid'"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 px-4 sm:px-6 h-full overflow-auto"
>
<AssetCard
v-for="asset in filteredAssets"
@@ -117,7 +117,7 @@
@update:rowSelection="handleRowSelectionChange"
@row-click="handleRowClick"
/>
<div v-else class="flex items-center justify-center py-12">
<div v-else class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading table...</span>
@@ -1,13 +1,13 @@
<template>
<div class="space-y-4 px-4">
<div class="rounded-md border">
<div class="px-4 h-full flex flex-col">
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
<template v-if="lockColumns">
<div v-if="hasRows" class="flex">
<!-- Left pane: frozen columns, no horizontal scroll -->
<div class="flex-shrink-0 border-r">
<div v-if="hasRows" class="flex h-full">
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
<table class="caption-bottom text-sm">
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in frozenHeaders(headerGroup)"
@@ -29,7 +29,7 @@
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
class="cursor-pointer hover:bg-muted/50 h-12"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@@ -47,10 +47,10 @@
</table>
</div>
<!-- Right pane: movable columns, own horizontal scrollbar -->
<div class="flex-1 min-w-0 overflow-x-auto">
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
<table class="min-w-full caption-bottom text-sm">
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in movableHeaders(headerGroup)"
@@ -75,7 +75,7 @@
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="cursor-pointer hover:bg-muted/50"
class="cursor-pointer hover:bg-muted/50 h-12"
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown"
@@ -100,7 +100,7 @@
<!-- Unlocked: standard single table -->
<Table v-else>
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
@@ -225,6 +225,19 @@ const movableCells = (row: Row<Asset>) =>
const hasRows = computed(() => table.getRowModel().rows.length > 0)
// Sync vertical scroll between the two panes (right pane owns the scrollbars).
const leftPane = ref<HTMLElement>()
const rightPane = ref<HTMLElement>()
let syncing = false
const syncScroll = (from?: HTMLElement, to?: HTMLElement) => {
if (syncing || !from || !to) return
syncing = true
to.scrollTop = from.scrollTop
requestAnimationFrame(() => { syncing = false })
}
const onRightScroll = () => syncScroll(rightPane.value, leftPane.value)
const onLeftScroll = () => syncScroll(leftPane.value, rightPane.value)
const emit = defineEmits<{
'update:sorting': [sorting: SortingState]
'update:columnVisibility': [visibility: VisibilityState]
@@ -405,4 +418,13 @@ watch(
-ms-user-select: text;
user-select: text;
}
/* Hide the left pane's vertical scrollbar (its scroll is synced from the right pane) */
.scrollbar-hide {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
</style>
@@ -7,7 +7,7 @@
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
<table class="caption-bottom text-sm">
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in frozenHeaders(headerGroup)"
@@ -51,7 +51,7 @@
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
<table class="min-w-full caption-bottom text-sm">
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in movableHeaders(headerGroup)"
@@ -102,7 +102,7 @@
<!-- Unlocked: standard single table -->
<Table v-else>
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
+51 -49
View File
@@ -1,57 +1,59 @@
<template>
<div class="relative h-full">
<div class="relative h-full flex flex-col">
<!-- Main Content -->
<div class="space-y-4">
<!-- Toolbar - Sticky -->
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<div class="flex flex-col flex-1 min-h-0">
<!-- Toolbar -->
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<TaskTableToolbar
v-model:status-filter="statusFilter"
v-model:type-filter="typeFilter"
v-model:episode-filter="episodeFilter"
v-model:assignee-filter="assigneeFilter"
v-model:context-filter="contextFilter"
v-model:search="searchQuery"
v-model:my-tasks-filter="myTasksFilter"
:column-visibility="columnVisibility"
:episodes="episodes"
:assignees="assignees"
:task-types="taskTypes"
:current-user-id="currentUserId"
:is-detail-panel-enabled="isDetailPanelEnabled"
@update:column-visibility="updateColumnVisibility"
@toggle-detail-panel="toggleDetailPanelEnabled"
/>
</div>
<!-- Task Count / Selection Count -->
<div class="flex items-center justify-between px-4 sm:px-6">
<div class="text-sm text-muted-foreground">
<span v-if="selectedCount > 0" class="font-medium text-foreground">
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
</span>
<span v-else-if="filteredTasks.length === tasks.length">
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
</span>
<span v-else>
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
</span>
v-model:status-filter="statusFilter"
v-model:type-filter="typeFilter"
v-model:episode-filter="episodeFilter"
v-model:assignee-filter="assigneeFilter"
v-model:context-filter="contextFilter"
v-model:search="searchQuery"
v-model:my-tasks-filter="myTasksFilter"
:column-visibility="columnVisibility"
:episodes="episodes"
:assignees="assignees"
:task-types="taskTypes"
:current-user-id="currentUserId"
:is-detail-panel-enabled="isDetailPanelEnabled"
@update:column-visibility="updateColumnVisibility"
@toggle-detail-panel="toggleDetailPanelEnabled"
/>
</div>
</div>
<!-- Data Table -->
<TasksDataTable
:tasks="filteredTasks"
:column-visibility="columnVisibility"
:project-id="projectId"
:is-loading="isLoading"
@row-click="handleRowClick"
@row-double-click="handleRowDoubleClick"
@context-menu="handleContextMenu"
@selection-change="handleSelectionChange"
@update:column-visibility="updateColumnVisibility"
@status-updated="handleStatusUpdated"
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
/>
<!-- Task Count / Selection Count -->
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
<div class="text-sm text-muted-foreground">
<span v-if="selectedCount > 0" class="font-medium text-foreground">
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
</span>
<span v-else-if="filteredTasks.length === tasks.length">
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
</span>
<span v-else>
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
</span>
</div>
</div>
<!-- Data Table -->
<div class="flex-1 min-h-0">
<TasksDataTable
:tasks="filteredTasks"
:column-visibility="columnVisibility"
:project-id="projectId"
:is-loading="isLoading"
@row-click="handleRowClick"
@row-double-click="handleRowDoubleClick"
@context-menu="handleContextMenu"
@selection-change="handleSelectionChange"
@update:column-visibility="updateColumnVisibility"
@status-updated="handleStatusUpdated"
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
/>
</div>
</div>
<!-- Task Detail Panel (Desktop + Mobile) -->
@@ -92,6 +92,49 @@
</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>
@@ -205,7 +248,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User } from 'lucide-vue-next'
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'
@@ -229,6 +272,7 @@ 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[]
@@ -244,6 +288,10 @@ interface Props {
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>()
@@ -257,6 +305,7 @@ const emit = defineEmits<{
'update:search': [value: string]
'update:column-visibility': [value: VisibilityState]
'update:my-tasks-filter': [value: boolean]
'update:project-filter': [value: number | null]
'toggle-detail-panel': []
}>()
@@ -298,7 +347,8 @@ const hasFilters = computed(() => {
props.assigneeFilter.length > 0 ||
props.contextFilter !== 'all' ||
props.search !== '' ||
props.myTasksFilter
props.myTasksFilter ||
(props.projectFilter ?? null) !== null
)
})
@@ -348,5 +398,8 @@ const clearFilters = () => {
emit('update:context-filter', 'all')
emit('update:search', '')
emit('update:my-tasks-filter', false)
if (props.projects) {
emit('update:project-filter', null)
}
}
</script>
@@ -1,7 +1,8 @@
<template>
<div class="rounded-md border">
<div class="px-4 h-full flex flex-col">
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
<Table>
<TableHeader>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
@@ -60,6 +61,7 @@
</template>
</TableBody>
</Table>
</div>
</div>
</template>
+359 -78
View File
@@ -1,124 +1,277 @@
<template>
<div class="h-full flex flex-col">
<!-- Header - Shot Task Style -->
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<div class="flex items-center justify-between">
<!-- Header -->
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-semibold">My Tasks</h1>
<p class="text-sm text-muted-foreground mt-1">
View and track your assigned tasks across all projects
</p>
</div>
<div class="flex items-center gap-4">
<div class="text-sm text-muted-foreground">
<span class="font-semibold">{{ tasks.length }}</span> total tasks
<span v-if="overdueTasks.length > 0" class="ml-4 text-destructive font-semibold">
{{ overdueTasks.length }} overdue
</span>
</div>
<div class="text-sm text-muted-foreground whitespace-nowrap">
<span class="font-semibold">{{ tasks.length }}</span> total
<span v-if="overdueTasks.length > 0" class="ml-3 text-destructive font-semibold">
{{ overdueTasks.length }} overdue
</span>
</div>
</div>
<!-- Filter Toolbar -->
<div class="flex flex-wrap gap-2 mt-4">
<!-- Project Filter -->
<Select v-model="selectedProjectId" @update:model-value="handleProjectChange">
<SelectTrigger class="w-[250px] h-8">
<SelectValue placeholder="All Projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Projects</SelectItem>
<SelectItem v-for="project in projects" :key="project.id" :value="String(project.id)">
{{ project.name }}
</SelectItem>
</SelectContent>
</Select>
<TaskTableToolbar
v-model:status-filter="statusFilter"
v-model:type-filter="typeFilter"
v-model:episode-filter="episodeFilter"
v-model:assignee-filter="assigneeFilter"
v-model:context-filter="contextFilter"
v-model:search="searchQuery"
v-model:my-tasks-filter="myTasksFilter"
v-model:project-filter="selectedProjectId"
:column-visibility="columnVisibility"
:episodes="episodes"
:assignees="assignees"
:task-types="taskTypes"
:projects="projects"
:current-user-id="currentUserId"
:is-detail-panel-enabled="isDetailPanelEnabled"
@update:column-visibility="updateColumnVisibility"
@toggle-detail-panel="toggleDetailPanelEnabled"
/>
</div>
<!-- Status Filter -->
<Select v-model="selectedStatus" @update:model-value="handleStatusChange">
<SelectTrigger class="w-[200px] h-8">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="not_started">Not Started</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="submitted">Submitted</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="retake">Retake</SelectItem>
</SelectContent>
</Select>
<!-- Task Count / Selection Count -->
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
<div class="text-sm text-muted-foreground">
<span v-if="selectedCount > 0" class="font-medium text-foreground">
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
</span>
<span v-else-if="filteredTasks.length === tasks.length">
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
</span>
<span v-else>
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
</span>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto px-4 sm:px-6 pb-6">
<!-- Task List -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex-1 min-h-0">
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading tasks...</span>
</div>
</div>
<div v-else-if="tasks.length === 0" class="text-center py-12">
<div v-else-if="filteredTasks.length === 0" class="text-center py-12 px-4 sm:px-6">
<ListTodo class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No tasks found</h3>
<p class="text-muted-foreground">
{{ selectedProjectId !== 'all' || selectedStatus !== 'all' ? 'Try adjusting your filters' : 'No tasks assigned to you' }}
{{ hasFilters ? 'Try adjusting your filters' : 'No tasks assigned to you' }}
</p>
</div>
<TaskList v-else @task-selected="handleTaskSelected" :tasks="tasks" />
<TasksDataTable
v-else
:tasks="filteredTasks"
:column-visibility="columnVisibility"
:project-id="activeProjectId"
:is-loading="isLoading"
@row-click="handleRowClick"
@row-double-click="handleRowDoubleClick"
@context-menu="handleContextMenu"
@selection-change="handleSelectionChange"
@update:column-visibility="updateColumnVisibility"
@status-updated="handleStatusUpdated"
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
/>
</div>
<!-- Task Detail Panel -->
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask.id"
@close="handleClosePanel"
@task-updated="handleTaskUpdated"
<!-- Task Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!showPanel"
v-model:mobile-open="showMobileDetail"
>
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask?.id || 0"
:is-open="true"
@close="closeDetailPanel"
@task-updated="handleTaskUpdated"
/>
</DetailPanelOverlay>
<!-- Context Menu for Bulk Actions -->
<TaskBulkActionsMenu
v-model:open="showContextMenu"
:position="contextMenuPosition"
:selected-count="selectedCount"
:selected-tasks="selectedTasks"
:project-members="projectMembers"
@status-selected="handleBulkStatusUpdate"
@assignee-selected="handleBulkAssignment"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, computed, watch, onMounted } from 'vue'
import { ListTodo } from 'lucide-vue-next'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import TaskList from '@/components/task/TaskList.vue'
import type { VisibilityState } from '@tanstack/vue-table'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import TaskTableToolbar from '@/components/task/TaskTableToolbar.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.vue'
import TaskBulkActionsMenu from '@/components/task/TaskBulkActionsMenu.vue'
import TasksDataTable from '@/components/task/TasksDataTable.vue'
import { taskService, type TaskListItem } from '@/services/task'
import { projectService, type Project } from '@/services/project'
import { TaskStatus } from '@/services/asset'
import { episodeService, type Episode } from '@/services/episode'
import { projectService, type Project, type ProjectMember } from '@/services/project'
import { useAuthStore } from '@/stores/auth'
import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast'
const router = useRouter()
const { toast } = useToast()
const authStore = useAuthStore()
// Detail panel composable
const {
isDetailPanelEnabled,
selectedEntity: selectedTask,
showMobileDetail,
showPanel,
toggleDetailPanelEnabled,
closeDetailPanel,
selectEntity: selectTask
} = useDetailPanel<TaskListItem>({
isDialogOpen: () => showContextMenu.value,
sessionStorageKey: 'myTasksView.detailPanelEnabled'
})
// State
const tasks = ref<TaskListItem[]>([])
const projects = ref<Project[]>([])
const selectedProjectId = ref<string>('all')
const selectedStatus = ref<string>('all')
const episodes = ref<Episode[]>([])
const projectMembers = ref<ProjectMember[]>([])
const isLoading = ref(false)
const selectedTask = ref<TaskListItem | null>(null)
const selectedProjectId = ref<number | null>(null)
// Context menu state
const showContextMenu = ref(false)
const contextMenuPosition = ref({ x: 0, y: 0 })
// Filters (client-side, matching TaskBrowser.vue's project-scoped Tasks page)
const statusFilter = ref<string[]>([])
const typeFilter = ref<string[]>([])
const episodeFilter = ref<number | null>(null)
const assigneeFilter = ref<number[]>([])
const contextFilter = ref<'all' | 'shots' | 'assets'>('all')
const searchQuery = ref('')
const myTasksFilter = ref(false)
// Table state
const columnVisibility = ref<VisibilityState>({})
// Selection state
const selectedTaskIds = ref<Set<number>>(new Set())
// Computed
const currentUserId = computed(() => authStore.user?.id || null)
const activeProjectId = computed(() => selectedProjectId.value ?? 0)
const overdueTasks = computed(() => {
const now = new Date()
return tasks.value.filter(task => {
if (!task.deadline) return false
const deadline = new Date(task.deadline)
return deadline < now && task.status !== 'approved'
return deadline < now && task.status !== TaskStatus.APPROVED
})
})
const assignees = computed(() => {
const uniqueAssignees = new Map<number, { id: number; name: string }>()
projectMembers.value.forEach((member) => {
uniqueAssignees.set(member.user_id, {
id: member.user_id,
name: `${member.user_first_name} ${member.user_last_name}`,
})
})
return Array.from(uniqueAssignees.values())
})
const taskTypes = computed(() => {
const types = new Set<string>()
tasks.value.forEach((task) => types.add(task.task_type))
return Array.from(types).sort()
})
const selectedTasks = computed(() => {
return filteredTasks.value.filter(task => selectedTaskIds.value.has(task.id))
})
const selectedCount = computed(() => selectedTaskIds.value.size)
const hasFilters = computed(() => {
return (
statusFilter.value.length > 0 ||
typeFilter.value.length > 0 ||
episodeFilter.value !== null ||
assigneeFilter.value.length > 0 ||
contextFilter.value !== 'all' ||
searchQuery.value !== '' ||
myTasksFilter.value
)
})
const filteredTasks = computed(() => {
let filtered = tasks.value
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(
(task) =>
task.name.toLowerCase().includes(query) ||
task.shot_name?.toLowerCase().includes(query) ||
task.asset_name?.toLowerCase().includes(query)
)
}
if (statusFilter.value.length > 0) {
filtered = filtered.filter((task) => statusFilter.value.includes(task.status))
}
if (typeFilter.value.length > 0) {
filtered = filtered.filter((task) => typeFilter.value.includes(task.task_type))
}
if (episodeFilter.value !== null) {
filtered = filtered.filter((task) => task.episode_id === episodeFilter.value)
}
if (assigneeFilter.value.length > 0) {
filtered = filtered.filter((task) =>
task.assigned_user_id ? assigneeFilter.value.includes(task.assigned_user_id) : false
)
}
if (contextFilter.value === 'shots') {
filtered = filtered.filter((task) => task.shot_id !== null && task.shot_id !== undefined)
} else if (contextFilter.value === 'assets') {
filtered = filtered.filter((task) => task.asset_id !== null && task.asset_id !== undefined)
}
return filtered
})
// Methods
async function fetchMyTasks() {
isLoading.value = true
try {
const projectId = selectedProjectId.value === 'all' ? undefined : parseInt(selectedProjectId.value)
const status = selectedStatus.value === 'all' ? undefined : selectedStatus.value
tasks.value = await taskService.getMyTasks(projectId, status)
tasks.value = await taskService.getMyTasks(selectedProjectId.value ?? undefined)
} catch (error) {
console.error('Failed to fetch my tasks:', error)
toast({
title: 'Failed to load tasks',
description: error instanceof Error ? error.message : 'An error occurred',
variant: 'destructive',
})
} finally {
isLoading.value = false
}
@@ -132,28 +285,156 @@ async function fetchProjects() {
}
}
function handleProjectChange() {
// Episode/assignee filters are project-scoped, so only populate them when a single
// project is selected (the toolbar already hides these filters when the list is empty).
async function fetchProjectScopedFilters() {
if (selectedProjectId.value === null) {
episodes.value = []
projectMembers.value = []
return
}
const projectId = selectedProjectId.value
try {
const [episodesResponse, membersResponse] = await Promise.all([
episodeService.getEpisodes(projectId),
projectService.getProjectMembers(projectId),
])
episodes.value = episodesResponse
projectMembers.value = membersResponse
} catch (error) {
console.error('Failed to fetch project-scoped filters:', error)
episodes.value = []
projectMembers.value = []
}
}
const handleRowClick = () => {
// Row click is reserved for selection; only double-click opens the detail panel
// (matches TaskBrowser.vue's convention for the project-scoped Tasks table).
}
const handleRowDoubleClick = (task: TaskListItem) => {
selectTask(task)
if (isDetailPanelEnabled.value && window.innerWidth < 1024) {
showMobileDetail.value = true
}
}
const handleTaskUpdated = () => {
fetchMyTasks()
}
function handleStatusChange() {
const handleStatusUpdated = (taskId: number, newStatus: string) => {
const taskIndex = tasks.value.findIndex((t) => t.id === taskId)
if (taskIndex !== -1) {
const updatedTasks = [...tasks.value]
updatedTasks[taskIndex] = {
...updatedTasks[taskIndex],
status: newStatus as TaskStatus,
}
tasks.value = updatedTasks
}
}
const handleSelectionChange = (taskIds: number[]) => {
selectedTaskIds.value = new Set(taskIds)
}
const handleContextMenu = (event: MouseEvent, _tasks: TaskListItem[]) => {
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
showContextMenu.value = true
}
const closeContextMenu = () => {
showContextMenu.value = false
}
const handleBulkStatusUpdate = async (status: string) => {
try {
const taskIds = selectedTasks.value.map(task => task.id)
if (taskIds.length === 0) return
isLoading.value = true
const result = await taskService.bulkUpdateStatus(taskIds, status)
toast({
title: 'Success',
description: `${result.success_count} ${result.success_count === 1 ? 'task' : 'tasks'} updated`,
})
await fetchMyTasks()
closeContextMenu()
} catch (error) {
console.error('Failed to update task status:', error)
toast({
title: 'Error',
description: 'Failed to update tasks. Please try again.',
variant: 'destructive',
})
} finally {
isLoading.value = false
}
}
const handleBulkAssignment = async (userId: number) => {
try {
const taskIds = selectedTasks.value.map(task => task.id)
if (taskIds.length === 0) return
isLoading.value = true
const result = await taskService.bulkAssignTasks(taskIds, userId)
toast({
title: 'Success',
description: `${result.success_count} ${result.success_count === 1 ? 'task' : 'tasks'} assigned`,
})
await fetchMyTasks()
closeContextMenu()
} catch (error) {
console.error('Failed to assign tasks:', error)
toast({
title: 'Error',
description: 'Failed to assign tasks. Please try again.',
variant: 'destructive',
})
} finally {
isLoading.value = false
}
}
const updateColumnVisibility = (visibility: VisibilityState) => {
columnVisibility.value = visibility
sessionStorage.setItem('myTasksView.columnVisibility', JSON.stringify(visibility))
}
function loadColumnVisibility() {
const saved = sessionStorage.getItem('myTasksView.columnVisibility')
if (saved) {
try {
columnVisibility.value = JSON.parse(saved)
} catch (error) {
console.error('Failed to parse saved column visibility:', error)
}
}
}
// Clear selection when filters change
watch([statusFilter, typeFilter, episodeFilter, assigneeFilter, contextFilter, searchQuery], () => {
selectedTaskIds.value = new Set()
})
// Refetch when the project filter changes (episode/assignee filters are project-scoped)
watch(selectedProjectId, () => {
episodeFilter.value = null
fetchMyTasks()
}
function handleTaskSelected(task: any) {
selectedTask.value = task
}
function handleClosePanel() {
selectedTask.value = null
}
function handleTaskUpdated() {
fetchMyTasks()
}
fetchProjectScopedFilters()
})
// Lifecycle
onMounted(async () => {
loadColumnVisibility()
await Promise.all([fetchMyTasks(), fetchProjects()])
})
</script>
@@ -18,8 +18,10 @@
</div>
<!-- Content -->
<div class="flex-1 overflow-auto">
<AssetBrowser :project-id="projectId" />
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<div class="flex-1 min-h-0 flex flex-col">
<AssetBrowser :project-id="projectId" />
</div>
</div>
</div>
</template>
@@ -13,12 +13,12 @@
</div>
<!-- Content -->
<div class="flex-1 overflow-auto">
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<!-- Task Browser -->
<div v-if="projectId">
<div v-if="projectId" class="flex-1 min-h-0 flex flex-col">
<TaskBrowser :project-id="projectId" />
</div>
<div v-else class="p-4 sm:p-6">
<!-- No project selected -->
<Card>