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> <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>
+10 -14
View File
@@ -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"
@@ -33,7 +34,7 @@
]" ]"
@click="handleRowClick(row.original, $event, index)" @click="handleRowClick(row.original, $event, index)"
@dblclick="handleRowDoubleClick(row.original)" @dblclick="handleRowDoubleClick(row.original)"
@contextmenu="handleContextMenu($event, index)" @contextmenu="handleContextMenu($event, row.original)"
> >
<TableCell <TableCell
v-for="cell in row.getVisibleCells()" v-for="cell in row.getVisibleCells()"
@@ -61,6 +62,7 @@
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -217,14 +219,16 @@ const handleRowClick = (task: Task, event: MouseEvent, index: number) => {
const end = Math.max(lastClickedIndex.value, index) const end = Math.max(lastClickedIndex.value, index)
const newSelection: Record<string, boolean> = {} 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++) { for (let i = start; i <= end; i++) {
const id = String(props.tasks[i].id) const id = String(displayedRows[i].original.id)
newSelection[id] = true newSelection[id] = true
} }
// Update rowSelection - create completely new object to trigger reactivity // Update rowSelection - create completely new object to trigger reactivity
rowSelection.value = newSelection rowSelection.value = newSelection
console.log('Shift-click selection updated:', rowSelection.value)
lastClickedIndex.value = index lastClickedIndex.value = index
} else if (event.ctrlKey || event.metaKey) { } else if (event.ctrlKey || event.metaKey) {
// Ctrl/Cmd+Click: Toggle selection // Ctrl/Cmd+Click: Toggle selection
@@ -250,17 +254,9 @@ const handleRowDoubleClick = (task: Task) => {
emit('row-double-click', task) emit('row-double-click', task)
} }
const handleContextMenu = (event: MouseEvent, index: number) => { const handleContextMenu = (event: MouseEvent, rightClickedTask: Task) => {
event.preventDefault() 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) const taskId = String(rightClickedTask.id)
// If right-clicked row is not selected, add it to selection // If right-clicked row is not selected, add it to selection
+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>