Compare commits
2 Commits
cd3628a255
...
841e786fdd
| Author | SHA1 | Date | |
|---|---|---|---|
| 841e786fdd | |||
| 26807984ee |
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
+64
-79
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user