Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 841e786fdd | |||
| 26807984ee |
@@ -173,43 +173,11 @@
|
|||||||
@confirm-delete="handleDeleteAsset"
|
@confirm-delete="handleDeleteAsset"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Asset Detail Panel (Desktop) with slide animation -->
|
<!-- Asset Detail Panel (Desktop + Mobile) -->
|
||||||
<Transition
|
<DetailPanelOverlay
|
||||||
enter-active-class="transition-transform duration-300 ease-out"
|
:visible="!!(showPanel && selectedAsset)"
|
||||||
enter-from-class="translate-x-full"
|
v-model:mobile-open="showMobileDetail"
|
||||||
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
|
<TaskDetailPanel
|
||||||
v-if="selectedTaskId"
|
v-if="selectedTaskId"
|
||||||
:key="selectedTaskId"
|
:key="selectedTaskId"
|
||||||
@@ -228,8 +196,7 @@
|
|||||||
@delete="deleteAsset"
|
@delete="deleteAsset"
|
||||||
@select-task="handleSelectTask"
|
@select-task="handleSelectTask"
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</DetailPanelOverlay>
|
||||||
</Sheet>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -253,7 +220,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import DetailPanelOverlay from "@/components/shared/DetailPanelOverlay.vue";
|
||||||
import AssetCard from "./AssetCard.vue";
|
import AssetCard from "./AssetCard.vue";
|
||||||
import AssetForm from "./AssetForm.vue";
|
import AssetForm from "./AssetForm.vue";
|
||||||
import AssetDetailPanel from "./AssetDetailPanel.vue";
|
import AssetDetailPanel from "./AssetDetailPanel.vue";
|
||||||
|
|||||||
@@ -1,31 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
<!-- Loading State -->
|
<DetailPanelLoading v-if="isLoading" label="Loading asset details..." />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
<DetailPanelError
|
||||||
<div v-else-if="error" class="p-6 text-center">
|
v-else-if="error"
|
||||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
title="Failed to load asset"
|
||||||
<h3 class="text-lg font-semibold mb-2">Failed to load asset</h3>
|
:message="error"
|
||||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
@retry="loadAssetDetails"
|
||||||
<Button @click="loadAssetDetails" variant="outline">
|
/>
|
||||||
<RefreshCw class="h-4 w-4 mr-2" />
|
|
||||||
Try Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Asset Details -->
|
<!-- Asset Details -->
|
||||||
<div v-else-if="asset" class="flex-1 overflow-y-auto">
|
<div v-else-if="asset" class="flex-1 overflow-y-auto">
|
||||||
<!-- Header -->
|
<DetailPanelHeader :title="asset.name" :deleted-at="asset.deleted_at" @close="$emit('close')">
|
||||||
<div class="p-6 border-b">
|
<template #badges>
|
||||||
<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">
|
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
class="w-2 h-2 rounded-full mr-1"
|
class="w-2 h-2 rounded-full mr-1"
|
||||||
@@ -37,14 +24,8 @@
|
|||||||
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||||
Deleted {{ formatDeletedDate(asset.deleted_at) }}
|
Deleted {{ formatDeletedDate(asset.deleted_at) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</template>
|
||||||
|
</DetailPanelHeader>
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
||||||
@@ -286,13 +267,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
AlertCircle, RefreshCw, X, Plus, MessageSquarePlus, Paperclip, Send
|
Plus, MessageSquarePlus, Paperclip, Send
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
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 AssetNotes from './AssetNotes.vue'
|
||||||
import AssetReferences from './AssetReferences.vue'
|
import AssetReferences from './AssetReferences.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
class="h-8 w-8 p-0 flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
@click="downloadFile(reference)"
|
@click="downloadFile(reference)"
|
||||||
>
|
>
|
||||||
<Download class="h-4 w-4" />
|
<Download class="h-4 w-4" />
|
||||||
|
|||||||
@@ -5,26 +5,11 @@
|
|||||||
<!-- Left Side - Filters -->
|
<!-- Left Side - Filters -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<!-- View Toggle -->
|
<!-- View Toggle -->
|
||||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
<SegmentedToggle
|
||||||
<Button
|
:options="viewModeOptions"
|
||||||
variant="ghost"
|
:model-value="viewMode"
|
||||||
size="sm"
|
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list')"
|
||||||
: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>
|
|
||||||
|
|
||||||
<!-- Category Filter -->
|
<!-- Category Filter -->
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -47,41 +32,23 @@
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No category found.</CommandEmpty>
|
<CommandEmpty>No category found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
value="all"
|
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>
|
<span>All Categories</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
v-for="category in categories"
|
v-for="category in categories"
|
||||||
:key="category.value"
|
:key="category.value"
|
||||||
:value="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" />
|
<component :is="category.icon" class="h-4 w-4 mr-2" />
|
||||||
<span>{{ category.label }}</span>
|
<span>{{ category.label }}</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -97,78 +64,12 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Column Visibility Control (only for list view) -->
|
<!-- Column Visibility Control (only for list view) -->
|
||||||
<Popover v-if="viewMode === 'list'">
|
<ColumnToggleList
|
||||||
<PopoverTrigger as-child>
|
v-if="viewMode === 'list'"
|
||||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
:columns="columnToggleOptions"
|
||||||
<Settings2 class="mr-2 h-4 w-4" />
|
:model-value="columnVisibility"
|
||||||
View
|
@update:model-value="$emit('update:column-visibility', $event)"
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Task Columns Toggle Button (only for list view) -->
|
<!-- Task Columns Toggle Button (only for list view) -->
|
||||||
<Button
|
<Button
|
||||||
@@ -184,28 +85,19 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Detail Panel Enable/Disable Toggle Button (only for list view) -->
|
<!-- Detail Panel Enable/Disable Toggle Button (only for list view) -->
|
||||||
<Button
|
<DetailPanelToggleButton
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
@click="$emit('toggle-detail-panel')"
|
:enabled="isDetailPanelEnabled"
|
||||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
@toggle="$emit('toggle-detail-panel')"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Lock First Columns Toggle Button (only for list view) -->
|
<!-- Lock First Columns Toggle Button (only for list view) -->
|
||||||
<Button
|
<Button
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
@click="$emit('toggle-column-lock')"
|
@click="$emit('toggle-column-lock')"
|
||||||
:variant="isColumnsLocked ? 'default' : 'outline'"
|
:variant="isColumnsLocked ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
|
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
|
||||||
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
|
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
|
||||||
>
|
>
|
||||||
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
|
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
|
||||||
@@ -213,16 +105,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Clear Filters -->
|
<!-- Clear Filters -->
|
||||||
<Button
|
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Side - Search and Actions -->
|
<!-- Right Side - Search and Actions -->
|
||||||
@@ -241,9 +124,8 @@
|
|||||||
<!-- Thumbnail Toggle -->
|
<!-- Thumbnail Toggle -->
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
@click="$emit('update:show-thumbnails', !showThumbnails)"
|
@click="$emit('update:show-thumbnails', !showThumbnails)"
|
||||||
class="h-8 w-8 p-0"
|
|
||||||
:title="showThumbnails ? 'Hide Thumbnails' : 'Show Thumbnails'"
|
:title="showThumbnails ? 'Hide Thumbnails' : 'Show Thumbnails'"
|
||||||
>
|
>
|
||||||
<ImageIcon v-if="!showThumbnails" class="h-4 w-4" />
|
<ImageIcon v-if="!showThumbnails" class="h-4 w-4" />
|
||||||
@@ -251,7 +133,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Create Asset 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" />
|
<Plus class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +145,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
|
LayoutGrid, List, Search, Package, Plus, ImageIcon, ImageOff,
|
||||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX,
|
ListTodo, ListX,
|
||||||
Lock, Unlock
|
Lock, Unlock
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -279,11 +161,15 @@ import {
|
|||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CheckableCommandItem,
|
||||||
} from '@/components/ui/command'
|
} 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 { VisibilityState } from '@tanstack/vue-table'
|
||||||
import type { Asset, AssetCategory } from '@/services/asset'
|
import type { Asset, AssetCategory } from '@/services/asset'
|
||||||
|
|
||||||
@@ -344,9 +230,19 @@ const hasFilters = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hiddenColumnsCount = computed(() => {
|
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
|
||||||
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
|
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
|
// Check if all task columns are visible
|
||||||
const allTaskColumnsVisible = computed(() => {
|
const allTaskColumnsVisible = computed(() => {
|
||||||
@@ -355,21 +251,9 @@ const allTaskColumnsVisible = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
|
||||||
const debouncedSearch = (value: string | number) => {
|
|
||||||
const searchValue = typeof value === 'string' ? value : String(value)
|
|
||||||
if (searchTimeout) clearTimeout(searchTimeout)
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
emit('update:search', searchValue)
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const toggleColumn = (columnId: string, value: any) => {
|
|
||||||
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
|
|
||||||
emit('update:column-visibility', newVisibility)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAllTaskColumns = () => {
|
const toggleAllTaskColumns = () => {
|
||||||
const newVisibility = { ...props.columnVisibility }
|
const newVisibility = { ...props.columnVisibility }
|
||||||
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
||||||
|
|||||||
@@ -304,8 +304,7 @@ export const createAssetColumns = (
|
|||||||
Button,
|
Button,
|
||||||
{
|
{
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
size: 'sm',
|
size: 'icon-sm',
|
||||||
class: 'h-8 w-8 p-0',
|
|
||||||
onMouseDown: (e: Event) => {
|
onMouseDown: (e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<Button @click="$emit('create')" v-if="canCreate && !searchQuery && statusFilter === 'all'">
|
<Button @click="$emit('create')" v-if="canCreate && !searchQuery && statusFilter === 'all'">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
Create Episode
|
New Episode
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<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" />
|
<MoreHorizontal class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<Button @click="showCreateDialog = true" v-if="canManage">
|
<Button @click="showCreateDialog = true" v-if="canManage">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
Create Episode
|
New Episode
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -84,12 +13,11 @@ import {
|
|||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
|
CheckableCommandItem,
|
||||||
} from '@/components/ui/command'
|
} from '@/components/ui/command'
|
||||||
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
|
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
|
||||||
import { TaskStatus } from '@/services/asset'
|
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -106,12 +34,13 @@ const emit = defineEmits<{
|
|||||||
const taskStatusesStore = useTaskStatusesStore()
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
const selectedFilters = ref<string[]>([])
|
const selectedFilters = ref<string[]>([])
|
||||||
|
|
||||||
|
// System status options (fallback if no project ID / statuses not loaded yet)
|
||||||
const defaultStatusOptions = [
|
const defaultStatusOptions = [
|
||||||
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
|
{ id: 'not_started', name: 'Not Started', color: '', is_system: true },
|
||||||
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
|
{ id: 'in_progress', name: 'In Progress', color: '', is_system: true },
|
||||||
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
|
{ id: 'submitted', name: 'Submitted', color: '', is_system: true },
|
||||||
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
|
{ id: 'approved', name: 'Approved', color: '', is_system: true },
|
||||||
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true },
|
{ id: 'retake', name: 'Retake', color: '', is_system: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const allStatuses = computed(() => {
|
const allStatuses = computed(() => {
|
||||||
@@ -160,3 +89,59 @@ const clearAllFilters = () => {
|
|||||||
const formatTaskType = (taskType: string) =>
|
const formatTaskType = (taskType: string) =>
|
||||||
taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||||
</script>
|
</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>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild @click.stop>
|
<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" />
|
<MoreHorizontal class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -160,45 +160,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Detail Panel (Overlay - Desktop) with Slide Animation -->
|
<!-- Detail Panel (Overlay - Desktop + Mobile) -->
|
||||||
<Transition
|
<DetailPanelOverlay
|
||||||
enter-active-class="transition-transform duration-300 ease-out"
|
:visible="!!(showPanel && selectedShot)"
|
||||||
enter-from-class="translate-x-full"
|
v-model:mobile-open="showMobileDetail"
|
||||||
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 && 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
|
<TaskDetailPanel
|
||||||
v-if="selectedTaskId"
|
v-if="selectedTaskId"
|
||||||
:key="selectedTaskId"
|
:key="selectedTaskId"
|
||||||
@@ -216,10 +182,10 @@
|
|||||||
@edit="editShot"
|
@edit="editShot"
|
||||||
@delete="deleteShot"
|
@delete="deleteShot"
|
||||||
@select-task="handleSelectTask"
|
@select-task="handleSelectTask"
|
||||||
|
@close="closeDetailPanel"
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</DetailPanelOverlay>
|
||||||
</Sheet>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Dialogs -->
|
<!-- Dialogs -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -317,10 +283,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import {
|
import DetailPanelOverlay from '@/components/shared/DetailPanelOverlay.vue'
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
} from '@/components/ui/sheet'
|
|
||||||
import ShotDeleteConfirmDialog from './ShotDeleteConfirmDialog.vue'
|
import ShotDeleteConfirmDialog from './ShotDeleteConfirmDialog.vue'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<!-- Actions Menu -->
|
<!-- Actions Menu -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild @click.stop>
|
<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" />
|
<MoreHorizontal class="h-4 w-4" />
|
||||||
<span class="sr-only">Shot actions</span>
|
<span class="sr-only">Shot actions</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,31 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
<!-- Loading State -->
|
<DetailPanelLoading v-if="isLoading" label="Loading shot details..." />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
<DetailPanelError
|
||||||
<div v-else-if="error" class="p-6 text-center">
|
v-else-if="error"
|
||||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
title="Failed to load shot"
|
||||||
<h3 class="text-lg font-semibold mb-2">Failed to load shot</h3>
|
:message="error"
|
||||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
@retry="loadShotDetails"
|
||||||
<Button @click="loadShotDetails" variant="outline">
|
/>
|
||||||
<RefreshCw class="h-4 w-4 mr-2" />
|
|
||||||
Try Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shot Details -->
|
<!-- Shot Details -->
|
||||||
<div v-else-if="shot" class="flex-1 overflow-y-auto">
|
<div v-else-if="shot" class="flex-1 overflow-y-auto">
|
||||||
<!-- Header -->
|
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
|
||||||
<div class="p-6 border-b">
|
<template #badges>
|
||||||
<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">
|
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
class="w-2 h-2 rounded-full mr-1"
|
class="w-2 h-2 rounded-full mr-1"
|
||||||
@@ -37,14 +24,8 @@
|
|||||||
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||||
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</template>
|
||||||
|
</DetailPanelHeader>
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
||||||
@@ -162,7 +143,7 @@
|
|||||||
|
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger as-child>
|
<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" />
|
<Send class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -204,13 +185,15 @@
|
|||||||
@click="$emit('select-task', task, 'infos')"
|
@click="$emit('select-task', task, 'infos')"
|
||||||
>
|
>
|
||||||
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||||
{{ task.assigned_user_name || 'Unassigned' }}
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs">
|
<TaskStatusBadge :status="getTaskStatusObject(task)" compact />
|
||||||
{{ formatTaskStatus(task.status.toString()) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,23 +334,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, Send
|
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
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 { taskService } from '@/services/task'
|
||||||
|
import { projectService, type ProjectMember } from '@/services/project'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
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
|
// Use TaskStatusInfo from shot service instead of local Task interface
|
||||||
interface Task extends TaskStatusInfo {
|
interface Task extends TaskStatusInfo {
|
||||||
id: number
|
id: number
|
||||||
name?: string
|
name?: string
|
||||||
assigned_user_name?: string
|
assigned_user_name?: string
|
||||||
|
assigned_user_avatar_url?: string | null
|
||||||
|
assigned_user_first_name?: string
|
||||||
|
assigned_user_last_name?: string
|
||||||
deadline?: string
|
deadline?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +385,8 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
|
const { getAvatarUrl } = useAvatarUrl()
|
||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const shot = ref<Shot | null>(null)
|
const shot = ref<Shot | null>(null)
|
||||||
@@ -398,6 +394,7 @@ const tasks = ref<Task[]>([])
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isCreatingTask = ref(false)
|
const isCreatingTask = ref(false)
|
||||||
|
const projectMembers = ref<ProjectMember[]>([])
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const frameCount = computed(() => {
|
const frameCount = computed(() => {
|
||||||
@@ -466,6 +463,10 @@ const loadShotDetails = async () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
|
shot.value = props.initialShot ?? await shotService.getShot(props.shotId)
|
||||||
|
await Promise.all([
|
||||||
|
taskStatusesStore.fetchProjectStatuses(props.projectId),
|
||||||
|
loadProjectMembers()
|
||||||
|
])
|
||||||
loadTasks() // No longer async - uses embedded data
|
loadTasks() // No longer async - uses embedded data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load shot details'
|
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 = () => {
|
const loadTasks = () => {
|
||||||
// Use task_details already embedded in shot data - no API call needed!
|
// Use task_details already embedded in shot data - no API call needed!
|
||||||
if (shot.value?.task_details) {
|
if (shot.value?.task_details) {
|
||||||
tasks.value = shot.value.task_details.map(taskInfo => ({
|
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,
|
id: taskInfo.task_id || 0,
|
||||||
task_type: taskInfo.task_type,
|
task_type: taskInfo.task_type,
|
||||||
status: taskInfo.status,
|
status: taskInfo.status,
|
||||||
assigned_user_id: taskInfo.assigned_user_id,
|
assigned_user_id: taskInfo.assigned_user_id,
|
||||||
// Add placeholder values for display compatibility
|
// Add placeholder values for display compatibility
|
||||||
name: taskInfo.task_type, // Use task_type as name for display
|
name: taskInfo.task_type, // Use task_type as name for display
|
||||||
assigned_user_name: undefined // Will be resolved if needed
|
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 {
|
} else {
|
||||||
tasks.value = []
|
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) => {
|
const handleAddTask = async (taskType: string) => {
|
||||||
isCreatingTask.value = true
|
isCreatingTask.value = true
|
||||||
try {
|
try {
|
||||||
@@ -512,18 +533,18 @@ const formatStatus = (status: ShotStatus) => {
|
|||||||
).join(' ')
|
).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTaskStatus = (status: string) => {
|
|
||||||
return status.split('_').map(word =>
|
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTaskType = (taskType: string) => {
|
const formatTaskType = (taskType: string) => {
|
||||||
return taskType.split('_').map(word =>
|
return taskType.split('_').map(word =>
|
||||||
word.charAt(0).toUpperCase() + word.slice(1)
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
).join(' ')
|
).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) => {
|
const getStatusVariant = (status: ShotStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ShotStatus.NOT_STARTED:
|
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 formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
|
|||||||
@@ -5,35 +5,11 @@
|
|||||||
<!-- Left Side - Filters -->
|
<!-- Left Side - Filters -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<!-- View Toggle -->
|
<!-- View Toggle -->
|
||||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
<SegmentedToggle
|
||||||
<Button
|
:options="viewModeOptions"
|
||||||
variant="ghost"
|
:model-value="viewMode"
|
||||||
size="sm"
|
@update:model-value="$emit('update:view-mode', $event as 'grid' | 'list' | 'table')"
|
||||||
: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>
|
|
||||||
|
|
||||||
<!-- Episode Filter -->
|
<!-- Episode Filter -->
|
||||||
<Popover v-if="episodes.length > 0">
|
<Popover v-if="episodes.length > 0">
|
||||||
@@ -56,40 +32,22 @@
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No episode found.</CommandEmpty>
|
<CommandEmpty>No episode found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
value="all"
|
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>
|
<span>All Episodes</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
v-for="episode in episodes"
|
v-for="episode in episodes"
|
||||||
:key="episode.id"
|
:key="episode.id"
|
||||||
:value="episode.id.toString()"
|
: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>
|
<span>{{ episode.name }}</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -97,7 +55,7 @@
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<!-- Task Status Filter (only for table view) -->
|
<!-- Task Status Filter (only for table view) -->
|
||||||
<ShotTaskStatusFilter
|
<TaskStatusFilter
|
||||||
v-if="viewMode === 'table'"
|
v-if="viewMode === 'table'"
|
||||||
:all-task-types="allTaskTypes"
|
:all-task-types="allTaskTypes"
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
@@ -105,79 +63,12 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Column Visibility Control (only for table view) -->
|
<!-- Column Visibility Control (only for table view) -->
|
||||||
<Popover v-if="viewMode === 'table'">
|
<ColumnToggleList
|
||||||
<PopoverTrigger as-child>
|
v-if="viewMode === 'table'"
|
||||||
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
:columns="columnToggleOptions"
|
||||||
<Settings2 class="mr-2 h-4 w-4" />
|
:model-value="columnVisibility"
|
||||||
View
|
@update:model-value="$emit('update:column-visibility', $event)"
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Task Columns Toggle Button (only for table view) -->
|
<!-- Task Columns Toggle Button (only for table view) -->
|
||||||
<Button
|
<Button
|
||||||
@@ -193,28 +84,19 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Detail Panel Enable/Disable Toggle Button (only for table view) -->
|
<!-- Detail Panel Enable/Disable Toggle Button (only for table view) -->
|
||||||
<Button
|
<DetailPanelToggleButton
|
||||||
v-if="viewMode === 'table'"
|
v-if="viewMode === 'table'"
|
||||||
@click="$emit('toggle-detail-panel')"
|
:enabled="isDetailPanelEnabled"
|
||||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
@toggle="$emit('toggle-detail-panel')"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Lock First Columns Toggle Button (only for table view) -->
|
<!-- Lock First Columns Toggle Button (only for table view) -->
|
||||||
<Button
|
<Button
|
||||||
v-if="viewMode === 'table'"
|
v-if="viewMode === 'table'"
|
||||||
@click="$emit('toggle-column-lock')"
|
@click="$emit('toggle-column-lock')"
|
||||||
:variant="isColumnsLocked ? 'default' : 'outline'"
|
:variant="isColumnsLocked ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
|
:class="isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''"
|
||||||
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
|
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
|
||||||
>
|
>
|
||||||
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
|
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
|
||||||
@@ -222,16 +104,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Clear Filters -->
|
<!-- Clear Filters -->
|
||||||
<Button
|
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Side - Search and Actions -->
|
<!-- Right Side - Search and Actions -->
|
||||||
@@ -248,10 +121,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- 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" />
|
<Layers class="h-4 w-4" />
|
||||||
</Button>
|
</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" />
|
<Plus class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +136,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
|
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
|
||||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX, Lock, Unlock
|
ListTodo, ListX, Lock, Unlock
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -278,11 +151,15 @@ import {
|
|||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CheckableCommandItem,
|
||||||
} from '@/components/ui/command'
|
} 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 { VisibilityState } from '@tanstack/vue-table'
|
||||||
import type { Episode } from '@/services/episode'
|
import type { Episode } from '@/services/episode'
|
||||||
import type { Shot } from '@/services/shot'
|
import type { Shot } from '@/services/shot'
|
||||||
@@ -338,9 +215,20 @@ const hasFilters = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hiddenColumnsCount = computed(() => {
|
// Column list shaped for ColumnToggleList (groups task-type columns under a labeled section)
|
||||||
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
|
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
|
// Check if all task columns are visible
|
||||||
const allTaskColumnsVisible = computed(() => {
|
const allTaskColumnsVisible = computed(() => {
|
||||||
@@ -349,21 +237,9 @@ const allTaskColumnsVisible = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
|
||||||
const debouncedSearch = (value: string | number) => {
|
|
||||||
const searchValue = typeof value === 'string' ? value : String(value)
|
|
||||||
if (searchTimeout) clearTimeout(searchTimeout)
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
emit('update:search', searchValue)
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const toggleColumn = (columnId: string, value: any) => {
|
|
||||||
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
|
|
||||||
emit('update:column-visibility', newVisibility)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAllTaskColumns = () => {
|
const toggleAllTaskColumns = () => {
|
||||||
const newVisibility = { ...props.columnVisibility }
|
const newVisibility = { ...props.columnVisibility }
|
||||||
const taskColumns = allColumns.value.filter(col => col.type === 'task')
|
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>
|
<TableCell>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild @click.stop>
|
<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" />
|
<MoreHorizontal class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -277,8 +277,7 @@ export const createShotColumns = (
|
|||||||
Button,
|
Button,
|
||||||
{
|
{
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
size: 'sm',
|
size: 'icon-sm',
|
||||||
class: 'h-8 w-8 p-0',
|
|
||||||
onMouseDown: (e: Event) => {
|
onMouseDown: (e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,18 +54,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Detail Panel - Desktop (Fixed Right Side) -->
|
<!-- Task Detail Panel (Desktop + Mobile) -->
|
||||||
<Transition
|
<DetailPanelOverlay
|
||||||
enter-active-class="transition-transform duration-300 ease-out"
|
:visible="!!showPanel"
|
||||||
enter-from-class="translate-x-full"
|
v-model:mobile-open="showMobileDetail"
|
||||||
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"
|
|
||||||
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
|
<TaskDetailPanel
|
||||||
v-if="selectedTask"
|
v-if="selectedTask"
|
||||||
@@ -74,21 +66,7 @@
|
|||||||
@close="closeDetailPanel"
|
@close="closeDetailPanel"
|
||||||
@task-updated="handleTaskUpdated"
|
@task-updated="handleTaskUpdated"
|
||||||
/>
|
/>
|
||||||
</div>
|
</DetailPanelOverlay>
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- Context Menu for Bulk Actions -->
|
<!-- Context Menu for Bulk Actions -->
|
||||||
<TaskBulkActionsMenu
|
<TaskBulkActionsMenu
|
||||||
@@ -106,7 +84,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import type { VisibilityState } from '@tanstack/vue-table'
|
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 { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import TaskTableToolbar from './TaskTableToolbar.vue'
|
import TaskTableToolbar from './TaskTableToolbar.vue'
|
||||||
import TaskDetailPanel from './TaskDetailPanel.vue'
|
import TaskDetailPanel from './TaskDetailPanel.vue'
|
||||||
|
|||||||
@@ -88,26 +88,31 @@
|
|||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<div class="h-px bg-border my-1" />
|
<div class="h-px bg-border my-1" />
|
||||||
|
|
||||||
<!-- Assign To section -->
|
<!-- Assign To submenu -->
|
||||||
<div class="py-1">
|
<DropdownMenu>
|
||||||
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
<DropdownMenuTrigger as-child>
|
||||||
Assign To
|
<button
|
||||||
</div>
|
: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">
|
<div v-if="projectMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
No members available
|
No members available
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="max-h-48 overflow-y-auto">
|
<DropdownMenuItem
|
||||||
<button
|
|
||||||
v-for="member in projectMembers"
|
v-for="member in projectMembers"
|
||||||
:key="member.user_id"
|
:key="member.user_id"
|
||||||
:disabled="isProcessing || hasMultipleProjects"
|
:disabled="isProcessing"
|
||||||
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"
|
|
||||||
@click="handleAssigneeSelected(member.user_id)"
|
@click="handleAssigneeSelected(member.user_id)"
|
||||||
>
|
>
|
||||||
{{ member.user_first_name }} {{ member.user_last_name }}
|
{{ member.user_first_name }} {{ member.user_last_name }}
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
</div>
|
</DropdownMenuContent>
|
||||||
</div>
|
</DropdownMenu>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Loading State -->
|
<DetailPanelLoading v-if="loading" label="Loading task details..." />
|
||||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
|
||||||
<div class="flex items-center gap-2">
|
<DetailPanelError
|
||||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
v-else-if="error"
|
||||||
<span class="text-muted-foreground">Loading task details...</span>
|
title="Failed to load task"
|
||||||
</div>
|
:message="error"
|
||||||
</div>
|
@retry="loadTask"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Task Details -->
|
<!-- Task Details -->
|
||||||
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
<div v-else-if="task" class="flex-1 flex flex-col min-h-0">
|
||||||
<!-- Header (Fixed) -->
|
<DetailPanelHeader class="flex-shrink-0" :title="task.name" @close="emit('close')">
|
||||||
<div class="flex-shrink-0 p-6 border-b">
|
<template #badges>
|
||||||
<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" />
|
<TaskStatusBadge :status="task.status" class="flex-shrink-0" />
|
||||||
</div>
|
</template>
|
||||||
|
</DetailPanelHeader>
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- Tabbed Content -->
|
<!-- Tabbed Content -->
|
||||||
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
|
<Tabs :default-value="initialTab || 'infos'" class="flex-1 flex flex-col min-h-0">
|
||||||
@@ -270,11 +262,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, computed } from 'vue'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -328,6 +323,7 @@ const authStore = useAuthStore()
|
|||||||
|
|
||||||
const task = ref<Task | null>(null)
|
const task = ref<Task | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
const localStatus = ref('')
|
const localStatus = ref('')
|
||||||
const notes = ref<ProductionNote[]>([])
|
const notes = ref<ProductionNote[]>([])
|
||||||
const attachments = ref<TaskAttachment[]>([])
|
const attachments = ref<TaskAttachment[]>([])
|
||||||
@@ -361,14 +357,16 @@ const canReassign = computed(() => {
|
|||||||
|
|
||||||
async function loadTask() {
|
async function loadTask() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
try {
|
try {
|
||||||
task.value = await taskService.getTask(props.taskId)
|
task.value = await taskService.getTask(props.taskId)
|
||||||
localStatus.value = task.value.status
|
localStatus.value = task.value.status
|
||||||
} catch (error: any) {
|
} catch (err: any) {
|
||||||
console.error('Error loading task:', error)
|
console.error('Error loading task:', err)
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to load task'
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: error.response?.data?.detail || 'Failed to load task',
|
description: error.value,
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -15,35 +15,11 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Context Filter Toggle -->
|
<!-- Context Filter Toggle -->
|
||||||
<div class="flex items-center border rounded-md h-8 p-0.5">
|
<SegmentedToggle
|
||||||
<Button
|
:options="contextFilterOptions"
|
||||||
variant="ghost"
|
:model-value="contextFilter"
|
||||||
size="sm"
|
@update:model-value="$emit('update:context-filter', $event as 'all' | 'shots' | 'assets')"
|
||||||
: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>
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
@@ -65,24 +41,15 @@
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No status found.</CommandEmpty>
|
<CommandEmpty>No status found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
v-for="status in statusOptions"
|
v-for="status in statusOptions"
|
||||||
:key="status.value"
|
:key="status.value"
|
||||||
:value="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>
|
<span>{{ status.label }}</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -110,24 +77,15 @@
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No type found.</CommandEmpty>
|
<CommandEmpty>No type found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
v-for="type in taskTypes"
|
v-for="type in taskTypes"
|
||||||
:key="type"
|
:key="type"
|
||||||
:value="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>
|
<span class="capitalize">{{ type.replace(/_/g, ' ') }}</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -155,40 +113,22 @@
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No episode found.</CommandEmpty>
|
<CommandEmpty>No episode found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
value="all"
|
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>
|
<span>All Episodes</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
v-for="episode in episodes"
|
v-for="episode in episodes"
|
||||||
:key="episode.id"
|
:key="episode.id"
|
||||||
:value="episode.id.toString()"
|
: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>
|
<span>{{ episode.name }}</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -216,24 +156,15 @@
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No assignee found.</CommandEmpty>
|
<CommandEmpty>No assignee found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CheckableCommandItem
|
||||||
v-for="assignee in assignees"
|
v-for="assignee in assignees"
|
||||||
:key="assignee.id"
|
:key="assignee.id"
|
||||||
:value="assignee.id.toString()"
|
: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>
|
<span>{{ assignee.name }}</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -241,64 +172,19 @@
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<!-- Column Visibility -->
|
<!-- Column Visibility -->
|
||||||
<Popover>
|
<div class="ml-auto">
|
||||||
<PopoverTrigger as-child>
|
<ColumnToggleList
|
||||||
<Button variant="outline" size="sm" class="ml-auto h-8 border-dashed">
|
:columns="allColumns"
|
||||||
<Settings2 class="mr-2 h-4 w-4" />
|
:model-value="columnVisibility"
|
||||||
View
|
@update:model-value="$emit('update:column-visibility', $event)"
|
||||||
<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>
|
</div>
|
||||||
<span>{{ column.label }}</span>
|
|
||||||
</CommandItem>
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<!-- Detail Panel Enable/Disable Toggle Button -->
|
<!-- Detail Panel Enable/Disable Toggle Button -->
|
||||||
<Button
|
<DetailPanelToggleButton
|
||||||
@click="$emit('toggle-detail-panel')"
|
:enabled="isDetailPanelEnabled"
|
||||||
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
|
@toggle="$emit('toggle-detail-panel')"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
@@ -312,23 +198,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clear Filters -->
|
<!-- Clear Filters -->
|
||||||
<Button
|
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Search, ListFilter, Tag, Film, Package, User, Settings2, Check, X, PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
|
import { Search, ListFilter, Tag, Film, Package, User } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -342,9 +219,14 @@ import {
|
|||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
CommandList,
|
||||||
|
CheckableCommandItem,
|
||||||
} from '@/components/ui/command'
|
} 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 { VisibilityState } from '@tanstack/vue-table'
|
||||||
import type { Episode } from '@/services/episode'
|
import type { Episode } from '@/services/episode'
|
||||||
|
|
||||||
@@ -387,6 +269,12 @@ const statusOptions = [
|
|||||||
{ value: 'retake', label: 'Retake' },
|
{ value: 'retake', label: 'Retake' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const contextFilterOptions = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'shots', icon: Film },
|
||||||
|
{ value: 'assets', icon: Package },
|
||||||
|
]
|
||||||
|
|
||||||
// Column definitions
|
// Column definitions
|
||||||
const allColumns = [
|
const allColumns = [
|
||||||
{ id: 'name', label: 'Task Name' },
|
{ 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
|
// Debounced search
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
|
||||||
const debouncedSearch = (value: string | number) => {
|
|
||||||
const searchValue = typeof value === 'string' ? value : String(value)
|
|
||||||
if (searchTimeout) clearTimeout(searchTimeout)
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
emit('update:search', searchValue)
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const toggleStatusFilter = (status: string) => {
|
const toggleStatusFilter = (status: string) => {
|
||||||
@@ -450,11 +327,6 @@ const toggleAssigneeFilter = (assigneeId: number) => {
|
|||||||
emit('update:assignee-filter', newFilter)
|
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 toggleMyTasksFilter = () => {
|
||||||
const newValue = !props.myTasksFilter
|
const newValue = !props.myTasksFilter
|
||||||
emit('update:my-tasks-filter', newValue)
|
emit('update:my-tasks-filter', newValue)
|
||||||
|
|||||||
@@ -143,8 +143,7 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
|
|||||||
Button,
|
Button,
|
||||||
{
|
{
|
||||||
variant: 'outline',
|
variant: 'outline',
|
||||||
size: 'sm',
|
size: 'icon-sm',
|
||||||
class: 'h-8 w-8 p-0',
|
|
||||||
},
|
},
|
||||||
() => h(ChevronDown, { class: 'h-4 w-4' })
|
() => h(ChevronDown, { class: 'h-4 w-4' })
|
||||||
),
|
),
|
||||||
@@ -334,8 +333,7 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
|
|||||||
Button,
|
Button,
|
||||||
{
|
{
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
size: 'sm',
|
size: 'icon-sm',
|
||||||
class: 'h-8 w-8 p-0',
|
|
||||||
onMouseDown: (e: Event) => {
|
onMouseDown: (e: Event) => {
|
||||||
e.stopPropagation()
|
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"
|
import { createContext } from "reka-ui"
|
||||||
|
|
||||||
export { default as Command } from "./Command.vue"
|
export { default as Command } from "./Command.vue"
|
||||||
|
export { default as CheckableCommandItem } from "./CheckableCommandItem.vue"
|
||||||
export { default as CommandDialog } from "./CommandDialog.vue"
|
export { default as CommandDialog } from "./CommandDialog.vue"
|
||||||
export { default as CommandEmpty } from "./CommandEmpty.vue"
|
export { default as CommandEmpty } from "./CommandEmpty.vue"
|
||||||
export { default as CommandGroup } from "./CommandGroup.vue"
|
export { default as CommandGroup } from "./CommandGroup.vue"
|
||||||
|
|||||||
@@ -19,24 +19,15 @@
|
|||||||
<Command class="bg-transparent border-0 p-0">
|
<Command class="bg-transparent border-0 p-0">
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<template v-for="column in baseColumns" :key="column.id">
|
<CheckableCommandItem
|
||||||
<CommandItem
|
v-for="column in baseColumns"
|
||||||
|
:key="column.id"
|
||||||
:value="column.id"
|
:value="column.id"
|
||||||
@select="toggleColumn(column.id, !isColumnVisible(column.id))"
|
:model-value="isColumnVisible(column.id)"
|
||||||
|
@update:model-value="(value) => toggleColumn(column.id, value)"
|
||||||
>
|
>
|
||||||
<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>
|
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
</template>
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -62,24 +53,15 @@
|
|||||||
<Command class="bg-transparent border-0 p-0">
|
<Command class="bg-transparent border-0 p-0">
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<template v-for="column in taskColumns" :key="column.id">
|
<CheckableCommandItem
|
||||||
<CommandItem
|
v-for="column in taskColumns"
|
||||||
|
:key="column.id"
|
||||||
:value="column.id"
|
:value="column.id"
|
||||||
@select="toggleColumn(column.id, !isColumnVisible(column.id))"
|
:model-value="isColumnVisible(column.id)"
|
||||||
|
@update:model-value="(value) => toggleColumn(column.id, value)"
|
||||||
>
|
>
|
||||||
<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>
|
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
|
||||||
</CommandItem>
|
</CheckableCommandItem>
|
||||||
</template>
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -136,14 +118,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
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 {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
|
CheckableCommandItem,
|
||||||
} from '@/components/ui/command'
|
} from '@/components/ui/command'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
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>
|
</p>
|
||||||
<Button @click="openCreateDialog" v-if="canCreateProjects && !searchQuery && statusFilter === 'all'">
|
<Button @click="openCreateDialog" v-if="canCreateProjects && !searchQuery && statusFilter === 'all'">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
Create Project
|
New Project
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user