Compare commits

..

2 Commits

Author SHA1 Message Date
indigo 841e786fdd Fix task assignee resolution and align task status styling in ShotDetailPanel
The Tasks list embedded assigned_user_name: undefined with a "will be
resolved if needed" comment that was never followed up on, so every task
showed "Unassigned" regardless of actual assignment. Resolve it from the
project members list, matching how the shot table's own assignment control
does it, and show an avatar next to the name. Also swap the hardcoded
status Badge/variant switch for the same TaskStatusBadge + task-statuses
store the shot table uses, so status colors and labels (including custom
per-project statuses) always match between the table and the panel.
2026-07-18 02:17:06 +08:00
indigo 26807984ee Phase 3: Unify controls across shot/asset/task toolbars and detail panels
Extracts duplication that built up as shot/asset/task features reached
parity: CheckableCommandItem/ColumnToggleList replace 14+ hand-rolled
checkbox-list blocks, shared toolbar pieces (debounced search, detail-panel
toggle, clear-filters, segmented view/context toggle) replace copy-pasted
markup in the three table toolbars, icon-only buttons standardize on the
icon-sm size, TaskBulkActionsMenu's Assign To submenu matches Set Status,
and a shared DetailPanelOverlay/Header/Loading/Error shell backs all three
detail panels (adding a previously-missing error state to the task panel).
2026-07-18 02:15:55 +08:00
34 changed files with 771 additions and 1184 deletions
+6 -39
View File
@@ -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')
+1 -2
View File
@@ -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>
@@ -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>
+9 -46
View File
@@ -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,
+1 -1
View File
@@ -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', {
+52 -176
View File
@@ -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>
+1 -2
View File
@@ -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()
}, },
+6 -28
View File
@@ -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 {
+51 -179
View File
@@ -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)
+2 -4
View File
@@ -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 }
}
+1 -1
View File
@@ -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>