Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f013e1cf25 | |||
| e628c99498 |
@@ -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"
|
||||||
|
|||||||
@@ -1,57 +1,59 @@
|
|||||||
<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"
|
||||||
v-model:episode-filter="episodeFilter"
|
v-model:episode-filter="episodeFilter"
|
||||||
v-model:assignee-filter="assigneeFilter"
|
v-model:assignee-filter="assigneeFilter"
|
||||||
v-model:context-filter="contextFilter"
|
v-model:context-filter="contextFilter"
|
||||||
v-model:search="searchQuery"
|
v-model:search="searchQuery"
|
||||||
v-model:my-tasks-filter="myTasksFilter"
|
v-model:my-tasks-filter="myTasksFilter"
|
||||||
:column-visibility="columnVisibility"
|
:column-visibility="columnVisibility"
|
||||||
:episodes="episodes"
|
:episodes="episodes"
|
||||||
:assignees="assignees"
|
:assignees="assignees"
|
||||||
:task-types="taskTypes"
|
:task-types="taskTypes"
|
||||||
:current-user-id="currentUserId"
|
:current-user-id="currentUserId"
|
||||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||||
@update:column-visibility="updateColumnVisibility"
|
@update:column-visibility="updateColumnVisibility"
|
||||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
@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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Data Table -->
|
<!-- Task Count / Selection Count -->
|
||||||
<TasksDataTable
|
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
|
||||||
:tasks="filteredTasks"
|
<div class="text-sm text-muted-foreground">
|
||||||
:column-visibility="columnVisibility"
|
<span v-if="selectedCount > 0" class="font-medium text-foreground">
|
||||||
:project-id="projectId"
|
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
|
||||||
:is-loading="isLoading"
|
</span>
|
||||||
@row-click="handleRowClick"
|
<span v-else-if="filteredTasks.length === tasks.length">
|
||||||
@row-double-click="handleRowDoubleClick"
|
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||||
@context-menu="handleContextMenu"
|
</span>
|
||||||
@selection-change="handleSelectionChange"
|
<span v-else>
|
||||||
@update:column-visibility="updateColumnVisibility"
|
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||||
@status-updated="handleStatusUpdated"
|
</span>
|
||||||
@bulk-status-change="(_, status) => handleBulkStatusUpdate(status)"
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Task Detail Panel (Desktop + Mobile) -->
|
<!-- Task Detail Panel (Desktop + Mobile) -->
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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()"
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Toolbar -->
|
<TaskTableToolbar
|
||||||
<div class="flex flex-wrap gap-2 mt-4">
|
v-model:status-filter="statusFilter"
|
||||||
<!-- Project Filter -->
|
v-model:type-filter="typeFilter"
|
||||||
<Select v-model="selectedProjectId" @update:model-value="handleProjectChange">
|
v-model:episode-filter="episodeFilter"
|
||||||
<SelectTrigger class="w-[250px] h-8">
|
v-model:assignee-filter="assigneeFilter"
|
||||||
<SelectValue placeholder="All Projects" />
|
v-model:context-filter="contextFilter"
|
||||||
</SelectTrigger>
|
v-model:search="searchQuery"
|
||||||
<SelectContent>
|
v-model:my-tasks-filter="myTasksFilter"
|
||||||
<SelectItem value="all">All Projects</SelectItem>
|
v-model:project-filter="selectedProjectId"
|
||||||
<SelectItem v-for="project in projects" :key="project.id" :value="String(project.id)">
|
:column-visibility="columnVisibility"
|
||||||
{{ project.name }}
|
:episodes="episodes"
|
||||||
</SelectItem>
|
:assignees="assignees"
|
||||||
</SelectContent>
|
:task-types="taskTypes"
|
||||||
</Select>
|
:projects="projects"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
|
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||||
|
@update:column-visibility="updateColumnVisibility"
|
||||||
|
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Task Count / Selection Count -->
|
||||||
<Select v-model="selectedStatus" @update:model-value="handleStatusChange">
|
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 mb-2">
|
||||||
<SelectTrigger class="w-[200px] h-8">
|
<div class="text-sm text-muted-foreground">
|
||||||
<SelectValue placeholder="All Statuses" />
|
<span v-if="selectedCount > 0" class="font-medium text-foreground">
|
||||||
</SelectTrigger>
|
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
|
||||||
<SelectContent>
|
</span>
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
<span v-else-if="filteredTasks.length === tasks.length">
|
||||||
<SelectItem value="not_started">Not Started</SelectItem>
|
{{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
</span>
|
||||||
<SelectItem value="submitted">Submitted</SelectItem>
|
<span v-else>
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
{{ filteredTasks.length }} of {{ tasks.length }} {{ tasks.length === 1 ? 'task' : 'tasks' }}
|
||||||
<SelectItem value="retake">Retake</SelectItem>
|
</span>
|
||||||
</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) -->
|
||||||
<TaskDetailPanel
|
<DetailPanelOverlay
|
||||||
v-if="selectedTask"
|
:visible="!!showPanel"
|
||||||
:task-id="selectedTask.id"
|
v-model:mobile-open="showMobileDetail"
|
||||||
@close="handleClosePanel"
|
>
|
||||||
@task-updated="handleTaskUpdated"
|
<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>
|
</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,8 +18,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||||
<AssetBrowser :project-id="projectId" />
|
<div class="flex-1 min-h-0 flex flex-col">
|
||||||
|
<AssetBrowser :project-id="projectId" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user