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> <template>
<div class="relative h-full"> <div class="relative h-full flex flex-col">
<!-- Main Content (Full Width) --> <!-- Main Content -->
<div class="space-y-4"> <div class="flex flex-col flex-1 min-h-0">
<!-- Toolbar - Sticky --> <!-- Toolbar -->
<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-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 <AssetTableToolbar
v-if="customTaskTypesLoaded" v-if="customTaskTypesLoaded"
:view-mode="viewMode" :view-mode="viewMode"
@@ -36,7 +36,7 @@
</div> </div>
<!-- Loading State --> <!-- 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="flex items-center gap-2">
<div <div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
@@ -46,7 +46,7 @@
</div> </div>
<!-- Error State --> <!-- 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" /> <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> <h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p> <p class="text-muted-foreground mb-4">{{ error }}</p>
@@ -61,7 +61,7 @@
v-else-if=" v-else-if="
filteredAssets.length === 0 && !searchQuery && !selectedCategory 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" /> <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> <h3 class="text-lg font-semibold mb-2">No assets yet</h3>
@@ -75,7 +75,7 @@
</div> </div>
<!-- No Results State --> <!-- 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" /> <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> <h3 class="text-lg font-semibold mb-2">No assets found</h3>
<p class="text-muted-foreground mb-4"> <p class="text-muted-foreground mb-4">
@@ -85,11 +85,11 @@
</div> </div>
<!-- Assets Grid/List --> <!-- Assets Grid/List -->
<div v-else> <div v-else class="flex-1 min-h-0">
<!-- Grid View --> <!-- Grid View -->
<div <div
v-if="viewMode === 'grid'" 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 <AssetCard
v-for="asset in filteredAssets" v-for="asset in filteredAssets"
@@ -117,7 +117,7 @@
@update:rowSelection="handleRowSelectionChange" @update:rowSelection="handleRowSelectionChange"
@row-click="handleRowClick" @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="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div> <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading table...</span> <span class="text-muted-foreground">Loading table...</span>
@@ -1,13 +1,13 @@
<template> <template>
<div class="space-y-4 px-4"> <div class="px-4 h-full flex flex-col">
<div class="rounded-md border"> <div class="rounded-md border flex-1 min-h-0 overflow-hidden">
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) --> <!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
<template v-if="lockColumns"> <template v-if="lockColumns">
<div v-if="hasRows" class="flex"> <div v-if="hasRows" class="flex h-full">
<!-- Left pane: frozen columns, no horizontal scroll --> <!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
<div class="flex-shrink-0 border-r"> <div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
<table class="caption-bottom text-sm"> <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"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead
v-for="header in frozenHeaders(headerGroup)" v-for="header in frozenHeaders(headerGroup)"
@@ -29,7 +29,7 @@
v-for="row in table.getRowModel().rows" v-for="row in table.getRowModel().rows"
:key="row.id" :key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined" :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 }" :class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)" @click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown" @mousedown="handleMouseDown"
@@ -47,10 +47,10 @@
</table> </table>
</div> </div>
<!-- Right pane: movable columns, own horizontal scrollbar --> <!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
<div class="flex-1 min-w-0 overflow-x-auto"> <div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
<table class="min-w-full caption-bottom text-sm"> <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"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead
v-for="header in movableHeaders(headerGroup)" v-for="header in movableHeaders(headerGroup)"
@@ -75,7 +75,7 @@
v-for="row in table.getRowModel().rows" v-for="row in table.getRowModel().rows"
:key="row.id" :key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined" :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 }" :class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
@click="handleRowClick(row.original, $event, row)" @click="handleRowClick(row.original, $event, row)"
@mousedown="handleMouseDown" @mousedown="handleMouseDown"
@@ -100,7 +100,7 @@
<!-- Unlocked: standard single table --> <!-- Unlocked: standard single table -->
<Table v-else> <Table v-else>
<TableHeader> <TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead
v-for="header in headerGroup.headers" v-for="header in headerGroup.headers"
@@ -225,6 +225,19 @@ const movableCells = (row: Row<Asset>) =>
const hasRows = computed(() => table.getRowModel().rows.length > 0) 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<{ const emit = defineEmits<{
'update:sorting': [sorting: SortingState] 'update:sorting': [sorting: SortingState]
'update:columnVisibility': [visibility: VisibilityState] 'update:columnVisibility': [visibility: VisibilityState]
@@ -405,4 +418,13 @@ watch(
-ms-user-select: text; -ms-user-select: text;
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> </style>
@@ -7,7 +7,7 @@
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) --> <!-- 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"> <div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
<table class="caption-bottom text-sm"> <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"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead
v-for="header in frozenHeaders(headerGroup)" v-for="header in frozenHeaders(headerGroup)"
@@ -51,7 +51,7 @@
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) --> <!-- 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"> <div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
<table class="min-w-full caption-bottom text-sm"> <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"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead
v-for="header in movableHeaders(headerGroup)" v-for="header in movableHeaders(headerGroup)"
@@ -102,7 +102,7 @@
<!-- Unlocked: standard single table --> <!-- Unlocked: standard single table -->
<Table v-else> <Table v-else>
<TableHeader> <TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead
v-for="header in headerGroup.headers" v-for="header in headerGroup.headers"
+7 -5
View File
@@ -1,9 +1,9 @@
<template> <template>
<div class="relative h-full"> <div class="relative h-full flex flex-col">
<!-- Main Content --> <!-- Main Content -->
<div class="space-y-4"> <div class="flex flex-col flex-1 min-h-0">
<!-- Toolbar - Sticky --> <!-- Toolbar -->
<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-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 <TaskTableToolbar
v-model:status-filter="statusFilter" v-model:status-filter="statusFilter"
v-model:type-filter="typeFilter" v-model:type-filter="typeFilter"
@@ -24,7 +24,7 @@
</div> </div>
<!-- Task Count / Selection Count --> <!-- Task Count / Selection Count -->
<div class="flex items-center justify-between px-4 sm:px-6"> <div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
<span v-if="selectedCount > 0" class="font-medium text-foreground"> <span v-if="selectedCount > 0" class="font-medium text-foreground">
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected {{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
@@ -39,6 +39,7 @@
</div> </div>
<!-- Data Table --> <!-- Data Table -->
<div class="flex-1 min-h-0">
<TasksDataTable <TasksDataTable
:tasks="filteredTasks" :tasks="filteredTasks"
:column-visibility="columnVisibility" :column-visibility="columnVisibility"
@@ -53,6 +54,7 @@
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)" @bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
/> />
</div> </div>
</div>
<!-- Task Detail Panel (Desktop + Mobile) --> <!-- Task Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay <DetailPanelOverlay
@@ -92,6 +92,49 @@
</PopoverContent> </PopoverContent>
</Popover> </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 --> <!-- Episode Filter -->
<Popover v-if="episodes.length > 0"> <Popover v-if="episodes.length > 0">
<PopoverTrigger as-child> <PopoverTrigger as-child>
@@ -205,7 +248,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -229,6 +272,7 @@ import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch' import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table' import type { VisibilityState } from '@tanstack/vue-table'
import type { Episode } from '@/services/episode' import type { Episode } from '@/services/episode'
import type { Project } from '@/services/project'
interface Props { interface Props {
statusFilter: string[] statusFilter: string[]
@@ -244,6 +288,10 @@ interface Props {
myTasksFilter: boolean myTasksFilter: boolean
currentUserId: number | null currentUserId: number | null
isDetailPanelEnabled: boolean 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 props = defineProps<Props>()
@@ -257,6 +305,7 @@ const emit = defineEmits<{
'update:search': [value: string] 'update:search': [value: string]
'update:column-visibility': [value: VisibilityState] 'update:column-visibility': [value: VisibilityState]
'update:my-tasks-filter': [value: boolean] 'update:my-tasks-filter': [value: boolean]
'update:project-filter': [value: number | null]
'toggle-detail-panel': [] 'toggle-detail-panel': []
}>() }>()
@@ -298,7 +347,8 @@ const hasFilters = computed(() => {
props.assigneeFilter.length > 0 || props.assigneeFilter.length > 0 ||
props.contextFilter !== 'all' || props.contextFilter !== 'all' ||
props.search !== '' || props.search !== '' ||
props.myTasksFilter props.myTasksFilter ||
(props.projectFilter ?? null) !== null
) )
}) })
@@ -348,5 +398,8 @@ const clearFilters = () => {
emit('update:context-filter', 'all') emit('update:context-filter', 'all')
emit('update:search', '') emit('update:search', '')
emit('update:my-tasks-filter', false) emit('update:my-tasks-filter', false)
if (props.projects) {
emit('update:project-filter', null)
}
} }
</script> </script>
@@ -1,7 +1,8 @@
<template> <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> <Table>
<TableHeader> <TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead <TableHead
v-for="header in headerGroup.headers" v-for="header in headerGroup.headers"
@@ -61,6 +62,7 @@
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
+354 -73
View File
@@ -1,124 +1,277 @@
<template> <template>
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
<!-- Header - Shot Task Style --> <!-- Header -->
<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-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"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h1 class="text-2xl font-semibold">My Tasks</h1> <h1 class="text-2xl font-semibold">My Tasks</h1>
<p class="text-sm text-muted-foreground mt-1"> <p class="text-sm text-muted-foreground mt-1">
View and track your assigned tasks across all projects View and track your assigned tasks across all projects
</p> </p>
</div> </div>
<div class="flex items-center gap-4"> <div class="text-sm text-muted-foreground whitespace-nowrap">
<div class="text-sm text-muted-foreground"> <span class="font-semibold">{{ tasks.length }}</span> total
<span class="font-semibold">{{ tasks.length }}</span> total tasks <span v-if="overdueTasks.length > 0" class="ml-3 text-destructive font-semibold">
<span v-if="overdueTasks.length > 0" class="ml-4 text-destructive font-semibold">
{{ overdueTasks.length }} overdue {{ overdueTasks.length }} overdue
</span> </span>
</div> </div>
</div> </div>
<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> </div>
<!-- Filter Toolbar --> <!-- Task Count / Selection Count -->
<div class="flex flex-wrap gap-2 mt-4"> <div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
<!-- Project Filter --> <div class="text-sm text-muted-foreground">
<Select v-model="selectedProjectId" @update:model-value="handleProjectChange"> <span v-if="selectedCount > 0" class="font-medium text-foreground">
<SelectTrigger class="w-[250px] h-8"> {{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
<SelectValue placeholder="All Projects" /> </span>
</SelectTrigger> <span v-else-if="filteredTasks.length === tasks.length">
<SelectContent> {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
<SelectItem value="all">All Projects</SelectItem> </span>
<SelectItem v-for="project in projects" :key="project.id" :value="String(project.id)"> <span v-else>
{{ project.name }} {{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
</SelectItem> </span>
</SelectContent>
</Select>
<!-- 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>
</div> </div>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 overflow-auto px-4 sm:px-6 pb-6"> <div class="flex-1 min-h-0">
<!-- Task List --> <div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div> <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading tasks...</span> <span class="text-muted-foreground">Loading tasks...</span>
</div> </div>
</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" /> <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> <h3 class="text-lg font-semibold mb-2">No tasks found</h3>
<p class="text-muted-foreground"> <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> </p>
</div> </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> </div>
<!-- Task Detail Panel --> <!-- Task Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!showPanel"
v-model:mobile-open="showMobileDetail"
>
<TaskDetailPanel <TaskDetailPanel
v-if="selectedTask" v-if="selectedTask"
:task-id="selectedTask.id" :task-id="selectedTask?.id || 0"
@close="handleClosePanel" :is-open="true"
@close="closeDetailPanel"
@task-updated="handleTaskUpdated" @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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ListTodo } from 'lucide-vue-next' import { ListTodo } from 'lucide-vue-next'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import type { VisibilityState } from '@tanstack/vue-table'
import TaskList from '@/components/task/TaskList.vue' import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import TaskTableToolbar from '@/components/task/TaskTableToolbar.vue'
import TaskDetailPanel from '@/components/task/TaskDetailPanel.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 { 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 // State
const tasks = ref<TaskListItem[]>([]) const tasks = ref<TaskListItem[]>([])
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
const selectedProjectId = ref<string>('all') const episodes = ref<Episode[]>([])
const selectedStatus = ref<string>('all') const projectMembers = ref<ProjectMember[]>([])
const isLoading = ref(false) 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 // Computed
const currentUserId = computed(() => authStore.user?.id || null)
const activeProjectId = computed(() => selectedProjectId.value ?? 0)
const overdueTasks = computed(() => { const overdueTasks = computed(() => {
const now = new Date() const now = new Date()
return tasks.value.filter(task => { return tasks.value.filter(task => {
if (!task.deadline) return false if (!task.deadline) return false
const deadline = new Date(task.deadline) 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 // Methods
async function fetchMyTasks() { async function fetchMyTasks() {
isLoading.value = true isLoading.value = true
try { try {
const projectId = selectedProjectId.value === 'all' ? undefined : parseInt(selectedProjectId.value) tasks.value = await taskService.getMyTasks(selectedProjectId.value ?? undefined)
const status = selectedStatus.value === 'all' ? undefined : selectedStatus.value
tasks.value = await taskService.getMyTasks(projectId, status)
} catch (error) { } catch (error) {
console.error('Failed to fetch my tasks:', 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 { } finally {
isLoading.value = false 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() 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() fetchMyTasks()
} fetchProjectScopedFilters()
})
function handleTaskSelected(task: any) {
selectedTask.value = task
}
function handleClosePanel() {
selectedTask.value = null
}
function handleTaskUpdated() {
fetchMyTasks()
}
// Lifecycle // Lifecycle
onMounted(async () => { onMounted(async () => {
loadColumnVisibility()
await Promise.all([fetchMyTasks(), fetchProjects()]) await Promise.all([fetchMyTasks(), fetchProjects()])
}) })
</script> </script>
@@ -18,10 +18,12 @@
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 overflow-auto"> <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" /> <AssetBrowser :project-id="projectId" />
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -13,9 +13,9 @@
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-hidden flex flex-col min-h-0">
<!-- Task Browser --> <!-- Task Browser -->
<div v-if="projectId"> <div v-if="projectId" class="flex-1 min-h-0 flex flex-col">
<TaskBrowser :project-id="projectId" /> <TaskBrowser :project-id="projectId" />
</div> </div>