Compare commits

...

2 Commits

Author SHA1 Message Date
indigo 841e786fdd Fix task assignee resolution and align task status styling in ShotDetailPanel
The Tasks list embedded assigned_user_name: undefined with a "will be
resolved if needed" comment that was never followed up on, so every task
showed "Unassigned" regardless of actual assignment. Resolve it from the
project members list, matching how the shot table's own assignment control
does it, and show an avatar next to the name. Also swap the hardcoded
status Badge/variant switch for the same TaskStatusBadge + task-statuses
store the shot table uses, so status colors and labels (including custom
per-project statuses) always match between the table and the panel.
2026-07-18 02:17:06 +08:00
indigo 26807984ee 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).
2026-07-18 02:15:55 +08:00
34 changed files with 771 additions and 1184 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>
+103 -100
View File
@@ -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>
@@ -204,13 +185,15 @@
@click="$emit('select-task', task, 'infos')"
>
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
<div class="text-sm text-muted-foreground">
{{ task.assigned_user_name || 'Unassigned' }}
<div class="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<Avatar class="h-5 w-5 flex-shrink-0" v-if="task.assigned_user_name">
<AvatarImage :src="getAvatarUrl(task.assigned_user_avatar_url, task.assigned_user_first_name, task.assigned_user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getTaskAssigneeInitials(task) }}</AvatarFallback>
</Avatar>
<span class="truncate">{{ task.assigned_user_name || 'Unassigned' }}</span>
</div>
<div>
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs">
{{ formatTaskStatus(task.status.toString()) }}
</Badge>
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
</div>
</div>
</div>
@@ -245,7 +228,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 +240,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 +250,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 +286,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 +298,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 +308,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,23 +334,34 @@
<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 { 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 TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
import { shotService, ShotStatus, type Shot, type TaskStatusInfo } from '@/services/shot'
import { taskService } from '@/services/task'
import { projectService, type ProjectMember } from '@/services/project'
import { useAuthStore } from '@/stores/auth'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
// Use TaskStatusInfo from shot service instead of local Task interface
interface Task extends TaskStatusInfo {
id: number
name?: string
assigned_user_name?: string
assigned_user_avatar_url?: string | null
assigned_user_first_name?: string
assigned_user_last_name?: string
deadline?: string
}
@@ -391,6 +385,8 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
const { getAvatarUrl } = useAvatarUrl()
// Reactive state
const shot = ref<Shot | null>(null)
@@ -398,6 +394,7 @@ const tasks = ref<Task[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isCreatingTask = ref(false)
const projectMembers = ref<ProjectMember[]>([])
// Computed properties
const frameCount = computed(() => {
@@ -422,14 +419,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
})
@@ -466,6 +463,10 @@ const loadShotDetails = async () => {
isLoading.value = true
error.value = null
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
await Promise.all([
taskStatusesStore.fetchProjectStatuses(props.projectId),
loadProjectMembers()
])
loadTasks() // No longer async - uses embedded data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
@@ -475,23 +476,43 @@ const loadShotDetails = async () => {
}
}
const loadProjectMembers = async () => {
try {
projectMembers.value = await projectService.getProjectMembers(props.projectId)
} catch (err) {
console.error('Failed to load project members:', err)
}
}
const loadTasks = () => {
// Use task_details already embedded in shot data - no API call needed!
if (shot.value?.task_details) {
tasks.value = shot.value.task_details.map(taskInfo => ({
id: taskInfo.task_id || 0,
task_type: taskInfo.task_type,
status: taskInfo.status,
assigned_user_id: taskInfo.assigned_user_id,
// Add placeholder values for display compatibility
name: taskInfo.task_type, // Use task_type as name for display
assigned_user_name: undefined // Will be resolved if needed
}))
tasks.value = shot.value.task_details.map(taskInfo => {
const assignedUser = projectMembers.value.find(member => member.user_id === taskInfo.assigned_user_id)
return {
id: taskInfo.task_id || 0,
task_type: taskInfo.task_type,
status: taskInfo.status,
assigned_user_id: taskInfo.assigned_user_id,
// Add placeholder values for display compatibility
name: taskInfo.task_type, // Use task_type as name for display
assigned_user_name: assignedUser ? `${assignedUser.user_first_name} ${assignedUser.user_last_name}` : undefined,
assigned_user_avatar_url: assignedUser?.user_avatar_url,
assigned_user_first_name: assignedUser?.user_first_name,
assigned_user_last_name: assignedUser?.user_last_name
}
})
} else {
tasks.value = []
}
}
// Resolve the project's actual status object (name + color) for a task, matching the shot table
const getTaskStatusObject = (task: Task) => {
const statusId = task.status.toString()
return taskStatusesStore.getStatusById(props.projectId, statusId) || statusId
}
const handleAddTask = async (taskType: string) => {
isCreatingTask.value = true
try {
@@ -507,23 +528,23 @@ const handleAddTask = async (taskType: string) => {
}
const formatStatus = (status: ShotStatus) => {
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(' ')
}
const getTaskAssigneeInitials = (task: Task) => {
const first = task.assigned_user_first_name?.charAt(0) || ''
const last = task.assigned_user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
const getStatusVariant = (status: ShotStatus) => {
switch (status) {
case ShotStatus.NOT_STARTED:
@@ -558,24 +579,6 @@ const getStatusColor = (status: ShotStatus) => {
}
}
const getTaskStatusVariant = (status: string | TaskStatus) => {
const statusStr = status.toString()
switch (statusStr) {
case 'not_started':
return 'secondary'
case 'in_progress':
return 'default'
case 'submitted':
return 'outline'
case 'approved':
return 'default'
case 'retake':
return 'destructive'
default:
return 'secondary'
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
@@ -589,7 +592,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>