Compare commits

...

2 Commits

Author SHA1 Message Date
indigo f013e1cf25 Fix wrong selected-task count in the task bulk-actions context menu
handleContextMenu (and the shift-click range-selection path) resolved the
target row via props.tasks[index], but index is the row's position in the
sorted/rendered row model (table.getRowModel().rows), not the raw prop
array — the two orders rarely match once the default created_at sort is
applied. This caused right-click (and shift-click) to select the wrong row,
corrupting the count shown in TaskBulkActionsMenu. Both call sites now
resolve the task from the actual row data instead of an index lookup into
the unsorted array, matching the pattern ShotsDataTable.vue already used
correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 13:19:42 +08:00
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
9 changed files with 531 additions and 175 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>
+10 -14
View File
@@ -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"
@@ -33,7 +34,7 @@
]"
@click="handleRowClick(row.original, $event, index)"
@dblclick="handleRowDoubleClick(row.original)"
@contextmenu="handleContextMenu($event, index)"
@contextmenu="handleContextMenu($event, row.original)"
>
<TableCell
v-for="cell in row.getVisibleCells()"
@@ -60,6 +61,7 @@
</template>
</TableBody>
</Table>
</div>
</div>
</template>
@@ -217,14 +219,16 @@ const handleRowClick = (task: Task, event: MouseEvent, index: number) => {
const end = Math.max(lastClickedIndex.value, index)
const newSelection: Record<string, boolean> = {}
// Index into the sorted/rendered row model, not props.tasks — the raw prop array
// order doesn't match the displayed (sorted) order.
const displayedRows = table.getRowModel().rows
for (let i = start; i <= end; i++) {
const id = String(props.tasks[i].id)
const id = String(displayedRows[i].original.id)
newSelection[id] = true
}
// Update rowSelection - create completely new object to trigger reactivity
rowSelection.value = newSelection
console.log('Shift-click selection updated:', rowSelection.value)
lastClickedIndex.value = index
} else if (event.ctrlKey || event.metaKey) {
// Ctrl/Cmd+Click: Toggle selection
@@ -250,17 +254,9 @@ const handleRowDoubleClick = (task: Task) => {
emit('row-double-click', task)
}
const handleContextMenu = (event: MouseEvent, index: number) => {
const handleContextMenu = (event: MouseEvent, rightClickedTask: Task) => {
event.preventDefault()
// Prevent context menu on empty table areas
if (props.tasks.length === 0) {
return
}
const rightClickedTask = props.tasks[index]
if (!rightClickedTask) return
const taskId = String(rightClickedTask.id)
// If right-clicked row is not selected, add it to selection
+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>