Phase 3: Unify controls across shot/asset/task toolbars and detail panels

Extracts duplication that built up as shot/asset/task features reached
parity: CheckableCommandItem/ColumnToggleList replace 14+ hand-rolled
checkbox-list blocks, shared toolbar pieces (debounced search, detail-panel
toggle, clear-filters, segmented view/context toggle) replace copy-pasted
markup in the three table toolbars, icon-only buttons standardize on the
icon-sm size, TaskBulkActionsMenu's Assign To submenu matches Set Status,
and a shared DetailPanelOverlay/Header/Loading/Error shell backs all three
detail panels (adding a previously-missing error state to the task panel).
This commit is contained in:
2026-07-18 02:15:55 +08:00
parent cd3628a255
commit 26807984ee
34 changed files with 714 additions and 1146 deletions
+25 -58
View File
@@ -173,63 +173,30 @@
@confirm-delete="handleDeleteAsset"
/>
</div>
<!-- Asset Detail Panel (Desktop) with slide animation -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="showPanel && selectedAsset"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel
v-else
:project-id="projectId"
:asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@select-task="handleSelectTask"
/>
</div>
</Transition>
<!-- Asset Detail Panel (Mobile) -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel
v-else-if="selectedAsset"
:project-id="projectId"
:asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@select-task="handleSelectTask"
/>
</SheetContent>
</Sheet>
<!-- Asset Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!(showPanel && selectedAsset)"
v-model:mobile-open="showMobileDetail"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel
v-else-if="selectedAsset"
:project-id="projectId"
:asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@select-task="handleSelectTask"
/>
</DetailPanelOverlay>
</div>
</template>
@@ -253,7 +220,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import DetailPanelOverlay from "@/components/shared/DetailPanelOverlay.vue";
import AssetCard from "./AssetCard.vue";
import AssetForm from "./AssetForm.vue";
import AssetDetailPanel from "./AssetDetailPanel.vue";
@@ -1,50 +1,31 @@
<template>
<div class="h-full flex flex-col">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<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 asset details...</span>
</div>
</div>
<DetailPanelLoading v-if="isLoading" label="Loading asset details..." />
<!-- Error State -->
<div v-else-if="error" class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load asset</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button @click="loadAssetDetails" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
<DetailPanelError
v-else-if="error"
title="Failed to load asset"
:message="error"
@retry="loadAssetDetails"
/>
<!-- Asset Details -->
<div v-else-if="asset" class="flex-1 overflow-y-auto">
<!-- Header -->
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': asset.deleted_at }">{{ asset.name }}</h2>
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(asset.status)"
></div>
{{ formatStatus(asset.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(asset.deleted_at) }}
</Badge>
</div>
<!-- Close Button -->
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
<template #badges>
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(asset.status)"
></div>
{{ formatStatus(asset.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(asset.deleted_at) }}
</Badge>
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
@@ -286,13 +267,16 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
AlertCircle, RefreshCw, X, Plus, MessageSquarePlus, Paperclip, Send
Plus, MessageSquarePlus, Paperclip, Send
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import AssetNotes from './AssetNotes.vue'
import AssetReferences from './AssetReferences.vue'
@@ -43,8 +43,8 @@
</div>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0 flex-shrink-0"
size="icon-sm"
class="flex-shrink-0"
@click="downloadFile(reference)"
>
<Download class="h-4 w-4" />
@@ -5,26 +5,11 @@
<!-- Left Side - Filters -->
<div class="flex flex-wrap gap-2">
<!-- View Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'grid' }"
@click="$emit('update:view-mode', 'grid')"
class="h-7 px-2"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'list' }"
@click="$emit('update:view-mode', 'list')"
class="h-7 px-2"
>
<List class="h-4 w-4" />
</Button>
</div>
<SegmentedToggle
:options="viewModeOptions"
:model-value="viewMode"
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list')"
/>
<!-- Category Filter -->
<Popover>
@@ -47,41 +32,23 @@
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="$emit('update:category-filter', 'all')"
:model-value="categoryFilter === 'all'"
@update:model-value="$emit('update:category-filter', 'all')"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
categoryFilter === 'all'
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Categories</span>
</CommandItem>
<CommandItem
</CheckableCommandItem>
<CheckableCommandItem
v-for="category in categories"
:key="category.value"
:value="category.value"
@select="$emit('update:category-filter', category.value)"
:model-value="categoryFilter === category.value"
@update:model-value="$emit('update:category-filter', category.value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
categoryFilter === category.value
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<component :is="category.icon" class="h-4 w-4 mr-2" />
<span>{{ category.label }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -97,78 +64,12 @@
/>
<!-- Column Visibility Control (only for list view) -->
<Popover v-if="viewMode === 'list'">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'default'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
<CommandGroup>
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Task Types
</div>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'task'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ColumnToggleList
v-if="viewMode === 'list'"
:columns="columnToggleOptions"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
<!-- Task Columns Toggle Button (only for list view) -->
<Button
@@ -184,28 +85,19 @@
</Button>
<!-- Detail Panel Enable/Disable Toggle Button (only for list view) -->
<Button
<DetailPanelToggleButton
v-if="viewMode === 'list'"
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Lock First Columns Toggle Button (only for list view) -->
<Button
v-if="viewMode === 'list'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="sm"
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
size="icon-sm"
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
@@ -213,16 +105,7 @@
</Button>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
<!-- Right Side - Search and Actions -->
@@ -241,9 +124,8 @@
<!-- Thumbnail Toggle -->
<Button
variant="outline"
size="sm"
size="icon-sm"
@click="$emit('update:show-thumbnails', !showThumbnails)"
class="h-8 w-8 p-0"
:title="showThumbnails ? 'Hide Thumbnails' : 'Show Thumbnails'"
>
<ImageIcon v-if="!showThumbnails" class="h-4 w-4" />
@@ -251,7 +133,7 @@
</Button>
<!-- Create Asset Button -->
<Button @click="$emit('create-asset')" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('create-asset')" size="icon-sm" title="Create Asset">
<Plus class="h-4 w-4" />
</Button>
</div>
@@ -263,7 +145,7 @@
import { computed } from 'vue'
import {
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX,
ListTodo, ListX,
Lock, Unlock
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
@@ -279,11 +161,15 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import TaskStatusFilter from './TaskStatusFilter.vue'
import TaskStatusFilter from '@/components/shared/TaskStatusFilter.vue'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Asset, AssetCategory } from '@/services/asset'
@@ -344,9 +230,19 @@ const hasFilters = computed(() => {
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
})
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
const columnToggleOptions = computed(() =>
allColumns.value.map(col => ({
id: col.id,
label: col.label,
group: col.type === 'task' ? 'Task Types' : undefined,
}))
)
const viewModeOptions = [
{ value: 'grid', icon: LayoutGrid },
{ value: 'list', icon: List },
]
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
@@ -355,21 +251,9 @@ const allTaskColumnsVisible = computed(() => {
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleAllTaskColumns = () => {
const newVisibility = { ...props.columnVisibility }
const taskColumns = allColumns.value.filter(col => col.type === 'task')
+3 -4
View File
@@ -302,10 +302,9 @@ export const createAssetColumns = (
default: () =>
h(
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
{
variant: 'ghost',
size: 'icon-sm',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
@@ -168,7 +168,7 @@
</p>
<Button @click="$emit('create')" v-if="canCreate && !searchQuery && statusFilter === 'all'">
<Plus class="h-4 w-4 mr-2" />
Create Episode
New Episode
</Button>
</div>
</div>
@@ -83,7 +83,7 @@
<!-- Actions -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -100,7 +100,7 @@
</p>
<Button @click="showCreateDialog = true" v-if="canManage">
<Plus class="h-4 w-4 mr-2" />
Create Episode
New Episode
</Button>
</div>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineEmits<{ clear: [] }>()
</script>
<template>
<Button variant="ghost" size="sm" class="h-8 px-2 lg:px-3" @click="$emit('clear')">
Reset
<X class="ml-2 h-4 w-4" />
</Button>
</template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Settings2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import type { VisibilityState } from '@tanstack/vue-table'
export interface ColumnOption {
id: string
label: string
/** Optional section label; columns without one are grouped first, unlabeled. */
group?: string
}
interface Props {
columns: ColumnOption[]
modelValue: VisibilityState
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: VisibilityState]
}>()
const isVisible = (columnId: string) => props.modelValue[columnId] !== false
const toggle = (columnId: string, value: boolean) => {
emit('update:modelValue', { ...props.modelValue, [columnId]: value })
}
const hiddenCount = computed(() => props.columns.filter(col => !isVisible(col.id)).length)
const groupedColumns = computed(() => {
const groups = new Map<string | undefined, ColumnOption[]>()
for (const column of props.columns) {
const key = column.group
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(column)
}
return Array.from(groups.entries()).map(([label, columns]) => ({ label, columns }))
})
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<template v-for="group in groupedColumns" :key="group.label ?? '__default__'">
<CommandGroup>
<div v-if="group.label" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ group.label }}
</div>
<CommandSeparator v-if="group.label" />
<CheckableCommandItem
v-for="column in group.columns"
:key="column.id"
:value="column.id"
:model-value="isVisible(column.id)"
@update:model-value="(value) => toggle(column.id, value)"
>
<span>{{ column.label }}</span>
</CheckableCommandItem>
</CommandGroup>
</template>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { AlertCircle, RefreshCw } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineProps<{ title: string; message: string }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<div class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">{{ title }}</h3>
<p class="text-muted-foreground mb-4">{{ message }}</p>
<Button @click="$emit('retry')" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
interface Props {
title: string
deletedAt?: string | null
}
defineProps<Props>()
defineEmits<{ close: [] }>()
</script>
<template>
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': deletedAt }">{{ title }}</h2>
<slot name="badges" />
</div>
<Button variant="ghost" size="icon-sm" class="flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ label: string }>()
</script>
<template>
<div class="flex items-center justify-center py-12">
<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">{{ label }}</span>
</div>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { Sheet, SheetContent } from '@/components/ui/sheet'
interface Props {
visible: boolean
mobileOpen: boolean
}
defineProps<Props>()
const emit = defineEmits<{ 'update:mobileOpen': [value: boolean] }>()
</script>
<template>
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="visible"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<slot />
</div>
</Transition>
<Sheet :open="mobileOpen" @update:open="emit('update:mobileOpen', $event)">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<slot />
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
defineProps<{ enabled: boolean }>()
defineEmits<{ toggle: [] }>()
</script>
<template>
<Button
@click="$emit('toggle')"
:variant="enabled ? 'default' : 'outline'"
size="icon-sm"
:class="enabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="enabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="enabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
export interface SegmentOption {
value: string
label?: string
icon?: unknown
title?: string
}
interface Props {
options: SegmentOption[]
modelValue: string
}
defineProps<Props>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
v-for="option in options"
:key="option.value"
variant="ghost"
size="sm"
:class="{ 'bg-muted': modelValue === option.value }"
:title="option.title"
class="h-7 px-2"
@click="$emit('update:modelValue', option.value)"
>
<component :is="option.icon" v-if="option.icon" class="h-4 w-4" />
<template v-if="option.label">{{ option.label }}</template>
</Button>
</div>
</template>
@@ -1,77 +1,6 @@
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Task Status
<Badge
v-if="selectedFilters.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ selectedFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search task status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<!-- All Tasks Option -->
<CommandGroup>
<CommandItem value="all" @select="clearAllFilters">
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.length === 0
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Tasks</span>
</CommandItem>
</CommandGroup>
<!-- Task Type Groups -->
<CommandGroup v-for="taskType in allTaskTypes" :key="taskType">
<CommandSeparator />
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ formatTaskType(taskType) }}
</div>
<CommandItem
v-for="status in allStatuses"
:key="`${taskType}:${status.id}`"
:value="`${taskType}:${status.id}`"
@select="toggleFilter(`${taskType}:${status.id}`)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.includes(`${taskType}:${status.id}`)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ListFilter, Check } from 'lucide-vue-next'
import { ListFilter } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@@ -84,12 +13,11 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/asset'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface Props {
@@ -106,12 +34,13 @@ const emit = defineEmits<{
const taskStatusesStore = useTaskStatusesStore()
const selectedFilters = ref<string[]>([])
// System status options (fallback if no project ID / statuses not loaded yet)
const defaultStatusOptions = [
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true },
{ id: 'not_started', name: 'Not Started', color: '', is_system: true },
{ id: 'in_progress', name: 'In Progress', color: '', is_system: true },
{ id: 'submitted', name: 'Submitted', color: '', is_system: true },
{ id: 'approved', name: 'Approved', color: '', is_system: true },
{ id: 'retake', name: 'Retake', color: '', is_system: true },
]
const allStatuses = computed(() => {
@@ -160,3 +89,59 @@ const clearAllFilters = () => {
const formatTaskType = (taskType: string) =>
taskType.charAt(0).toUpperCase() + taskType.slice(1)
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Task Status
<Badge
v-if="selectedFilters.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ selectedFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search task status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<!-- All Tasks Option -->
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="selectedFilters.length === 0"
@update:model-value="clearAllFilters"
>
<span>All Tasks</span>
</CheckableCommandItem>
</CommandGroup>
<!-- Task Type Groups -->
<CommandGroup v-for="taskType in allTaskTypes" :key="taskType">
<CommandSeparator />
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ formatTaskType(taskType) }}
</div>
<CheckableCommandItem
v-for="status in allStatuses"
:key="`${taskType}:${status.id}`"
:value="`${taskType}:${status.id}`"
:model-value="selectedFilters.includes(`${taskType}:${status.id}`)"
@update:model-value="toggleFilter(`${taskType}:${status.id}`)"
>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
</div>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
+9 -46
View File
@@ -120,7 +120,7 @@
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild @click.stop>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -160,45 +160,11 @@
</div>
</div>
<!-- Detail Panel (Overlay - Desktop) with Slide Animation -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
<!-- Detail Panel (Overlay - Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!(showPanel && selectedShot)"
v-model:mobile-open="showMobileDetail"
>
<div
v-if="showPanel && selectedShot"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadShots"
/>
<ShotDetailPanel
v-else
:project-id="projectId"
:shot-id="selectedShot.id"
:initial-shot="selectedShot"
:all-task-types="allTaskTypes"
@edit="editShot"
@delete="deleteShot"
@select-task="handleSelectTask"
@close="closeDetailPanel"
/>
</div>
</Transition>
</div>
<!-- Mobile Detail Sheet -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
@@ -216,10 +182,10 @@
@edit="editShot"
@delete="deleteShot"
@select-task="handleSelectTask"
@close="closeDetailPanel"
/>
</SheetContent>
</Sheet>
</DetailPanelOverlay>
</div>
<!-- Dialogs -->
<div class="space-y-4">
@@ -317,10 +283,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Sheet,
SheetContent,
} from '@/components/ui/sheet'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import ShotDeleteConfirmDialog from './ShotDeleteConfirmDialog.vue'
import {
DropdownMenu,
+1 -1
View File
@@ -21,7 +21,7 @@
<!-- Actions Menu -->
<DropdownMenu>
<DropdownMenuTrigger asChild @click.stop>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon-sm" class="opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal class="h-4 w-4" />
<span class="sr-only">Shot actions</span>
</Button>
@@ -1,50 +1,31 @@
<template>
<div class="h-full flex flex-col">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<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 shot details...</span>
</div>
</div>
<DetailPanelLoading v-if="isLoading" label="Loading shot details..." />
<!-- Error State -->
<div v-else-if="error" class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load shot</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button @click="loadShotDetails" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
<DetailPanelError
v-else-if="error"
title="Failed to load shot"
:message="error"
@retry="loadShotDetails"
/>
<!-- Shot Details -->
<div v-else-if="shot" class="flex-1 overflow-y-auto">
<!-- Header -->
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': shot.deleted_at }">{{ shot.name }}</h2>
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(shot.status)"
></div>
{{ formatStatus(shot.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
</Badge>
</div>
<!-- Close Button -->
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
<template #badges>
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(shot.status)"
></div>
{{ formatStatus(shot.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(shot.deleted_at) }}
</Badge>
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
@@ -101,7 +82,7 @@
</div>
<!-- Progress Bar -->
<div class="w-full bg-muted rounded-full h-2">
<div
<div
class="bg-primary h-2 rounded-full transition-all duration-300"
:style="{ width: `${progressPercentage}%` }"
></div>
@@ -162,7 +143,7 @@
<Popover>
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0" title="Publish Version" :disabled="tasks.length === 0">
<Button variant="ghost" size="icon-sm" title="Publish Version" :disabled="tasks.length === 0">
<Send class="h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -245,7 +226,7 @@
</PopoverContent>
</Popover>
</div>
<div class="text-center py-8">
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No notes yet</p>
@@ -257,9 +238,9 @@
<TabsContent value="assets" class="flex-1 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Linked Assets</h3>
<Button
v-if="canLinkAssets"
size="sm"
<Button
v-if="canLinkAssets"
size="sm"
variant="outline"
@click="$emit('link-asset')"
>
@@ -267,7 +248,7 @@
Link Asset
</Button>
</div>
<div class="text-center py-8">
<Package class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No assets linked</p>
@@ -303,7 +284,7 @@
</PopoverContent>
</Popover>
</div>
<div class="text-center py-8">
<Image class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No reference files</p>
@@ -315,9 +296,9 @@
<TabsContent value="design" class="flex-1 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold">Design Information</h3>
<Button
v-if="canEditDesign"
size="sm"
<Button
v-if="canEditDesign"
size="sm"
variant="outline"
@click="$emit('edit-design')"
>
@@ -325,18 +306,18 @@
Edit Design
</Button>
</div>
<div class="space-y-4">
<div>
<Label class="text-xs text-muted-foreground">Camera Notes</Label>
<p class="text-sm mt-1 text-muted-foreground">No camera notes</p>
</div>
<div>
<Label class="text-xs text-muted-foreground">Lighting Notes</Label>
<p class="text-sm mt-1 text-muted-foreground">No lighting notes</p>
</div>
<div>
<Label class="text-xs text-muted-foreground">Animation Notes</Label>
<p class="text-sm mt-1 text-muted-foreground">No animation notes</p>
@@ -351,13 +332,16 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, Send
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task'
@@ -422,14 +406,14 @@ const taskStatusCounts = computed(() => {
approved: 0,
retake: 0
}
tasks.value.forEach(task => {
const statusKey = task.status.toString() // Convert enum to string
if (counts.hasOwnProperty(statusKey)) {
counts[statusKey as keyof typeof counts]++
}
})
return counts
})
@@ -507,19 +491,19 @@ const handleAddTask = async (taskType: string) => {
}
const formatStatus = (status: ShotStatus) => {
return status.split('_').map(word =>
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskStatus = (status: string) => {
return status.split('_').map(word =>
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskType = (taskType: string) => {
return taskType.split('_').map(word =>
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
@@ -589,7 +573,7 @@ const formatDeletedDate = (deletedAt: string) => {
const date = new Date(deletedAt)
const now = new Date()
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60))
if (diffInHours < 24) {
return `${diffInHours}h ago`
} else {
+53 -177
View File
@@ -5,35 +5,11 @@
<!-- Left Side - Filters -->
<div class="flex flex-wrap gap-2">
<!-- View Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'grid' }"
@click="$emit('update:view-mode', 'grid')"
class="h-7 px-2"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'list' }"
@click="$emit('update:view-mode', 'list')"
class="h-7 px-2"
>
<List class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'table' }"
@click="$emit('update:view-mode', 'table')"
class="h-7 px-2"
>
<Table2 class="h-4 w-4" />
</Button>
</div>
<SegmentedToggle
:options="viewModeOptions"
:model-value="viewMode"
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list' | 'table')"
/>
<!-- Episode Filter -->
<Popover v-if="episodes.length > 0">
@@ -56,40 +32,22 @@
<CommandList>
<CommandEmpty>No episode found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="$emit('update:episode-filter', null)"
:model-value="episodeFilter === null"
@update:model-value="$emit('update:episode-filter', null)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === null
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Episodes</span>
</CommandItem>
<CommandItem
</CheckableCommandItem>
<CheckableCommandItem
v-for="episode in episodes"
:key="episode.id"
:value="episode.id.toString()"
@select="$emit('update:episode-filter', episode.id)"
:model-value="episodeFilter === episode.id"
@update:model-value="$emit('update:episode-filter', episode.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === episode.id
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ episode.name }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -97,7 +55,7 @@
</Popover>
<!-- Task Status Filter (only for table view) -->
<ShotTaskStatusFilter
<TaskStatusFilter
v-if="viewMode === 'table'"
:all-task-types="allTaskTypes"
:project-id="projectId"
@@ -105,79 +63,12 @@
/>
<!-- Column Visibility Control (only for table view) -->
<Popover v-if="viewMode === 'table'">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'default'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
<CommandGroup>
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
All Task Types
</div>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'task'"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ColumnToggleList
v-if="viewMode === 'table'"
:columns="columnToggleOptions"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
<!-- Task Columns Toggle Button (only for table view) -->
<Button
@@ -193,28 +84,19 @@
</Button>
<!-- Detail Panel Enable/Disable Toggle Button (only for table view) -->
<Button
<DetailPanelToggleButton
v-if="viewMode === 'table'"
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Lock First Columns Toggle Button (only for table view) -->
<Button
v-if="viewMode === 'table'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="sm"
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
size="icon-sm"
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
@@ -222,16 +104,7 @@
</Button>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
<!-- Right Side - Search and Actions -->
@@ -248,10 +121,10 @@
</div>
<!-- Action Buttons -->
<Button @click="$emit('bulk-create')" variant="outline" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('bulk-create')" variant="outline" size="icon-sm">
<Layers class="h-4 w-4" />
</Button>
<Button @click="$emit('create-shot')" size="sm" class="h-8 w-8 p-0">
<Button @click="$emit('create-shot')" size="icon-sm" title="Create Shot">
<Plus class="h-4 w-4" />
</Button>
</div>
@@ -261,9 +134,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
import {
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX, Lock, Unlock
ListTodo, ListX, Lock, Unlock
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -278,11 +151,15 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import ShotTaskStatusFilter from './ShotTaskStatusFilter.vue'
import TaskStatusFilter from '@/components/shared/TaskStatusFilter.vue'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
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 { Shot } from '@/services/shot'
@@ -338,9 +215,20 @@ const hasFilters = computed(() => {
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
})
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
const columnToggleOptions = computed(() =>
allColumns.value.map(col => ({
id: col.id,
label: col.label,
group: col.type === 'task' ? 'All Task Types' : undefined,
}))
)
const viewModeOptions = [
{ value: 'grid', icon: LayoutGrid },
{ value: 'list', icon: List },
{ value: 'table', icon: Table2 },
]
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
@@ -349,21 +237,9 @@ const allTaskColumnsVisible = computed(() => {
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleAllTaskColumns = () => {
const newVisibility = { ...props.columnVisibility }
const taskColumns = allColumns.value.filter(col => col.type === 'task')
@@ -1,204 +0,0 @@
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Task Status
<Badge
v-if="selectedFilters.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ selectedFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search task status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<!-- All Tasks Option -->
<CommandGroup>
<CommandItem
value="all"
@select="clearAllFilters"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.length === 0
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Tasks</span>
</CommandItem>
</CommandGroup>
<!-- Task Type Groups -->
<CommandGroup
v-for="taskType in allTaskTypes"
:key="taskType"
>
<CommandSeparator />
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ formatTaskType(taskType) }}
</div>
<CommandItem
v-for="status in allStatuses"
:key="`${taskType}:${status.id}`"
:value="`${taskType}:${status.id}`"
@select="toggleFilter(`${taskType}:${status.id}`)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedFilters.includes(`${taskType}:${status.id}`)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ListFilter, Check } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/shot'
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface Props {
allTaskTypes: string[]
projectId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'filter-changed': [filter: string]
}>()
// Use the shared task statuses store
const taskStatusesStore = useTaskStatusesStore()
const selectedFilters = ref<string[]>([])
// Get loading state from store
const isLoading = computed(() => {
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
})
// System status options (fallback if no project ID)
const defaultStatusOptions = [
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
]
// Combine system and custom statuses
const allStatuses = computed(() => {
if (!props.projectId) {
return defaultStatusOptions
}
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
if (!statusData) {
return defaultStatusOptions
}
// Convert system statuses to the format expected by TaskStatusBadge
const systemStatusList = statusData.system_statuses.map(status => ({
id: status.id,
name: status.name,
color: status.color,
is_system: status.is_system
}))
// Convert custom statuses to the format expected by TaskStatusBadge
const customStatusList = statusData.statuses.map(status => ({
id: status.id,
name: status.name,
color: status.color,
is_system: false
}))
return [...systemStatusList, ...customStatusList]
})
// Load custom statuses when component mounts or projectId changes
const loadStatuses = async () => {
if (!props.projectId) {
return
}
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (error) {
console.error('Failed to load task statuses:', error)
}
}
onMounted(() => {
loadStatuses()
})
watch(() => props.projectId, () => {
loadStatuses()
})
const toggleFilter = (filter: string) => {
const index = selectedFilters.value.indexOf(filter)
if (index > -1) {
selectedFilters.value.splice(index, 1)
} else {
selectedFilters.value.push(filter)
}
// Emit the filters as a comma-separated string, or empty string if none selected
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
emit('filter-changed', apiFilter)
}
const clearAllFilters = () => {
selectedFilters.value = []
emit('filter-changed', '')
}
const formatTaskType = (taskType: string) => {
return taskType.charAt(0).toUpperCase() + taskType.slice(1)
}
</script>
@@ -144,7 +144,7 @@
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild @click.stop>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Button variant="ghost" size="icon-sm">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
+3 -4
View File
@@ -275,10 +275,9 @@ export const createShotColumns = (
default: () =>
h(
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
{
variant: 'ghost',
size: 'icon-sm',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
+13 -35
View File
@@ -54,41 +54,19 @@
/>
</div>
<!-- Task Detail Panel - Desktop (Fixed Right Side) -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
<!-- Task Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!showPanel"
v-model:mobile-open="showMobileDetail"
>
<div
v-if="showPanel"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask?.id || 0"
:is-open="true"
@close="closeDetailPanel"
@task-updated="handleTaskUpdated"
/>
</div>
</Transition>
<!-- Task Detail Panel - Mobile (Sheet) -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask?.id || 0"
:is-open="true"
@close="closeDetailPanel"
@task-updated="handleTaskUpdated"
/>
</SheetContent>
</Sheet>
<TaskDetailPanel
v-if="selectedTask"
:task-id="selectedTask?.id || 0"
:is-open="true"
@close="closeDetailPanel"
@task-updated="handleTaskUpdated"
/>
</DetailPanelOverlay>
<!-- Context Menu for Bulk Actions -->
<TaskBulkActionsMenu
@@ -106,7 +84,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import type { VisibilityState } from '@tanstack/vue-table'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
import { useToast } from '@/components/ui/toast/use-toast'
import TaskTableToolbar from './TaskTableToolbar.vue'
import TaskDetailPanel from './TaskDetailPanel.vue'
@@ -88,26 +88,31 @@
<!-- Divider -->
<div class="h-px bg-border my-1" />
<!-- Assign To section -->
<div class="py-1">
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Assign To
</div>
<div v-if="projectMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground">
No members available
</div>
<div v-else class="max-h-48 overflow-y-auto">
<!-- Assign To submenu -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
:disabled="isProcessing || hasMultipleProjects"
class="w-full text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground rounded-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-between"
>
<span>Assign To</span>
<ChevronRight class="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-48 max-h-48 overflow-y-auto" side="right" align="start">
<div v-if="projectMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground">
No members available
</div>
<DropdownMenuItem
v-for="member in projectMembers"
:key="member.user_id"
:disabled="isProcessing || hasMultipleProjects"
class="w-full text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground rounded-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isProcessing"
@click="handleAssigneeSelected(member.user_id)"
>
{{ member.user_first_name }} {{ member.user_last_name }}
</button>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PopoverContent>
</Popover>
</template>
@@ -1,29 +1,21 @@
<template>
<div class="flex flex-col h-full">
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<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 task details...</span>
</div>
</div>
<DetailPanelLoading v-if="loading" label="Loading task details..." />
<DetailPanelError
v-else-if="error"
title="Failed to load task"
:message="error"
@retry="loadTask"
/>
<!-- Task Details -->
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
<!-- Header (Fixed) -->
<div class="flex-shrink-0 p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate">{{ task.name }}</h2>
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
</div>
<!-- Close Button -->
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
<template #badges>
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
</template>
</DetailPanelHeader>
<!-- Tabbed Content -->
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
@@ -270,11 +262,14 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { X, Play, Upload, UserPlus, Calendar, User } from 'lucide-vue-next'
import { Play, Upload, UserPlus, Calendar, User } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
import {
Select,
SelectContent,
@@ -328,6 +323,7 @@ const authStore = useAuthStore()
const task = ref<Task | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const localStatus = ref('')
const notes = ref<ProductionNote[]>([])
const attachments = ref<TaskAttachment[]>([])
@@ -361,14 +357,16 @@ const canReassign = computed(() => {
async function loadTask() {
loading.value = true
error.value = null
try {
task.value = await taskService.getTask(props.taskId)
localStatus.value = task.value.status
} catch (error: any) {
console.error('Error loading task:', error)
} catch (err: any) {
console.error('Error loading task:', err)
error.value = err.response?.data?.detail || 'Failed to load task'
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to load task',
description: error.value,
variant: 'destructive'
})
} finally {
+52 -180
View File
@@ -15,35 +15,11 @@
</Button>
<!-- Context Filter Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{'bg-muted':contextFilter === 'all'}"
@click="$emit('update:context-filter', 'all')"
class="h-7 px-2"
>
All
</Button>
<Button
variant="ghost"
size="sm"
:class="{'bg-muted':contextFilter === 'shots'}"
@click="$emit('update:context-filter', 'shots')"
class="h-7 px-2"
>
<Film class="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{'bg-muted': contextFilter === 'assets'}"
@click="$emit('update:context-filter', 'assets')"
class="h-7 px-2"
>
<Package class="h-4 w-4 mr-1" />
</Button>
</div>
<SegmentedToggle
:options="contextFilterOptions"
:model-value="contextFilter"
@update:model-value="$emit('update:context-filter', $event as 'all' | 'shots' | 'assets')"
/>
<!-- Status Filter -->
<Popover>
<PopoverTrigger as-child>
@@ -65,24 +41,15 @@
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="status in statusOptions"
:key="status.value"
:value="status.value"
@select="toggleStatusFilter(status.value)"
:model-value="statusFilter.includes(status.value)"
@update:model-value="toggleStatusFilter(status.value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
statusFilter.includes(status.value)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ status.label }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -110,24 +77,15 @@
<CommandList>
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="type in taskTypes"
:key="type"
:value="type"
@select="toggleTypeFilter(type)"
:model-value="typeFilter.includes(type)"
@update:model-value="toggleTypeFilter(type)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
typeFilter.includes(type)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="capitalize">{{ type.replace(/_/g, ' ') }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -155,40 +113,22 @@
<CommandList>
<CommandEmpty>No episode found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="$emit('update:episode-filter', null)"
:model-value="episodeFilter === null"
@update:model-value="$emit('update:episode-filter', null)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === null
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Episodes</span>
</CommandItem>
<CommandItem
</CheckableCommandItem>
<CheckableCommandItem
v-for="episode in episodes"
:key="episode.id"
:value="episode.id.toString()"
@select="$emit('update:episode-filter', episode.id)"
:model-value="episodeFilter === episode.id"
@update:model-value="$emit('update:episode-filter', episode.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === episode.id
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ episode.name }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -216,24 +156,15 @@
<CommandList>
<CommandEmpty>No assignee found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="assignee in assignees"
:key="assignee.id"
:value="assignee.id.toString()"
@select="toggleAssigneeFilter(assignee.id)"
:model-value="assigneeFilter.includes(assignee.id)"
@update:model-value="toggleAssigneeFilter(assignee.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
assigneeFilter.includes(assignee.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ assignee.name }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -241,64 +172,19 @@
</Popover>
<!-- Column Visibility -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="ml-auto h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="column in allColumns"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div class="ml-auto">
<ColumnToggleList
:columns="allColumns"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
</div>
<!-- Detail Panel Enable/Disable Toggle Button -->
<Button
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
<DetailPanelToggleButton
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Search -->
<div class="relative flex-1">
@@ -312,23 +198,14 @@
</div>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User, Settings2, Check, X, PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
import { computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
@@ -342,9 +219,14 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CheckableCommandItem,
} from '@/components/ui/command'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
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'
@@ -387,6 +269,12 @@ const statusOptions = [
{ value: 'retake', label: 'Retake' },
]
const contextFilterOptions = [
{ value: 'all', label: 'All' },
{ value: 'shots', icon: Film },
{ value: 'assets', icon: Package },
]
// Column definitions
const allColumns = [
{ id: 'name', label: 'Task Name' },
@@ -414,19 +302,8 @@ const hasFilters = computed(() => {
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.filter(col => props.columnVisibility[col.id] === false).length
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleStatusFilter = (status: string) => {
@@ -450,11 +327,6 @@ const toggleAssigneeFilter = (assigneeId: number) => {
emit('update:assignee-filter', newFilter)
}
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleMyTasksFilter = () => {
const newValue = !props.myTasksFilter
emit('update:my-tasks-filter', newValue)
+2 -4
View File
@@ -143,8 +143,7 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
Button,
{
variant: 'outline',
size: 'sm',
class: 'h-8 w-8 p-0',
size: 'icon-sm',
},
() => h(ChevronDown, { class: 'h-4 w-4' })
),
@@ -334,8 +333,7 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
Button,
{
variant: 'ghost',
size: 'sm',
class: 'h-8 w-8 p-0',
size: 'icon-sm',
onMouseDown: (e: Event) => {
e.stopPropagation()
},
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { Checkbox } from "@/components/ui/checkbox"
import CommandItem from "./CommandItem.vue"
interface Props {
value: string
modelValue: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:modelValue": [value: boolean]
}>()
</script>
<template>
<CommandItem :value="value" @select="emit('update:modelValue', !modelValue)">
<Checkbox
:model-value="modelValue"
tabindex="-1"
class="mr-2 pointer-events-none"
/>
<slot />
</CommandItem>
</template>
@@ -2,6 +2,7 @@ import type { Ref } from "vue"
import { createContext } from "reka-ui"
export { default as Command } from "./Command.vue"
export { default as CheckableCommandItem } from "./CheckableCommandItem.vue"
export { default as CommandDialog } from "./CommandDialog.vue"
export { default as CommandEmpty } from "./CommandEmpty.vue"
export { default as CommandGroup } from "./CommandGroup.vue"
@@ -19,24 +19,15 @@
<Command class="bg-transparent border-0 p-0">
<CommandList>
<CommandGroup>
<template v-for="column in baseColumns" :key="column.id">
<CommandItem
:value="column.id"
@select="toggleColumn(column.id, !isColumnVisible(column.id))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isColumnVisible(column.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
</CommandItem>
</template>
<CheckableCommandItem
v-for="column in baseColumns"
:key="column.id"
:value="column.id"
:model-value="isColumnVisible(column.id)"
@update:model-value="(value) => toggleColumn(column.id, value)"
>
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -62,24 +53,15 @@
<Command class="bg-transparent border-0 p-0">
<CommandList>
<CommandGroup>
<template v-for="column in taskColumns" :key="column.id">
<CommandItem
:value="column.id"
@select="toggleColumn(column.id, !isColumnVisible(column.id))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isColumnVisible(column.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
</CommandItem>
</template>
<CheckableCommandItem
v-for="column in taskColumns"
:key="column.id"
:value="column.id"
:model-value="isColumnVisible(column.id)"
@update:model-value="(value) => toggleColumn(column.id, value)"
>
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -136,14 +118,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Check, ChevronDown, ChevronRight, Columns, ListChecks } from 'lucide-vue-next'
import { ChevronDown, ChevronRight, Columns, ListChecks } from 'lucide-vue-next'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
CheckableCommandItem,
} from '@/components/ui/command'
import {
Collapsible,
@@ -0,0 +1,11 @@
export function useDebouncedSearch(onSearch: (value: string) => void, delay = 300) {
let timeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => onSearch(searchValue), delay)
}
return { debouncedSearch }
}
+1 -1
View File
@@ -210,7 +210,7 @@
</p>
<Button @click="openCreateDialog" v-if="canCreateProjects && !searchQuery && statusFilter === 'all'">
<Plus class="h-4 w-4 mr-2" />
Create Project
New Project
</Button>
</div>