Phase 3: Unify controls across shot/asset/task toolbars and detail panels
Extracts duplication that built up as shot/asset/task features reached parity: CheckableCommandItem/ColumnToggleList replace 14+ hand-rolled checkbox-list blocks, shared toolbar pieces (debounced search, detail-panel toggle, clear-filters, segmented view/context toggle) replace copy-pasted markup in the three table toolbars, icon-only buttons standardize on the icon-sm size, TaskBulkActionsMenu's Assign To submenu matches Set Status, and a shared DetailPanelOverlay/Header/Loading/Error shell backs all three detail panels (adding a previously-missing error state to the task panel).
This commit is contained in:
@@ -1,50 +1,31 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span class="text-muted-foreground">Loading shot details...</span>
|
||||
</div>
|
||||
</div>
|
||||
<DetailPanelLoading v-if="isLoading" label="Loading shot details..." />
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="p-6 text-center">
|
||||
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Failed to load shot</h3>
|
||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
||||
<Button @click="loadShotDetails" variant="outline">
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<DetailPanelError
|
||||
v-else-if="error"
|
||||
title="Failed to load shot"
|
||||
:message="error"
|
||||
@retry="loadShotDetails"
|
||||
/>
|
||||
|
||||
<!-- Shot Details -->
|
||||
<div v-else-if="shot" class="flex-1 overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': shot.deleted_at }">{{ shot.name }}</h2>
|
||||
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full mr-1"
|
||||
:class="getStatusColor(shot.status)"
|
||||
></div>
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
<!-- Deletion status indicator for admins -->
|
||||
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DetailPanelHeader :title="shot.name" :deleted-at="shot.deleted_at" @close="$emit('close')">
|
||||
<template #badges>
|
||||
<Badge :variant="getStatusVariant(shot.status)" class="text-xs flex-shrink-0">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full mr-1"
|
||||
:class="getStatusColor(shot.status)"
|
||||
></div>
|
||||
{{ formatStatus(shot.status) }}
|
||||
</Badge>
|
||||
<!-- Deletion status indicator for admins -->
|
||||
<Badge v-if="authStore.isAdmin && shot.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
|
||||
Deleted {{ formatDeletedDate(shot.deleted_at) }}
|
||||
</Badge>
|
||||
</template>
|
||||
</DetailPanelHeader>
|
||||
|
||||
<!-- Tabbed Content -->
|
||||
<Tabs default-value="infos" class="flex-1 flex flex-col">
|
||||
@@ -101,7 +82,7 @@
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
></div>
|
||||
@@ -162,7 +143,7 @@
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0" title="Publish Version" :disabled="tasks.length === 0">
|
||||
<Button variant="ghost" size="icon-sm" title="Publish Version" :disabled="tasks.length === 0">
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -245,7 +226,7 @@
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center py-8">
|
||||
<MessageSquare class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">No notes yet</p>
|
||||
@@ -257,9 +238,9 @@
|
||||
<TabsContent value="assets" class="flex-1 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Linked Assets</h3>
|
||||
<Button
|
||||
v-if="canLinkAssets"
|
||||
size="sm"
|
||||
<Button
|
||||
v-if="canLinkAssets"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('link-asset')"
|
||||
>
|
||||
@@ -267,7 +248,7 @@
|
||||
Link Asset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center py-8">
|
||||
<Package class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">No assets linked</p>
|
||||
@@ -303,7 +284,7 @@
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center py-8">
|
||||
<Image class="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">No reference files</p>
|
||||
@@ -315,9 +296,9 @@
|
||||
<TabsContent value="design" class="flex-1 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold">Design Information</h3>
|
||||
<Button
|
||||
v-if="canEditDesign"
|
||||
size="sm"
|
||||
<Button
|
||||
v-if="canEditDesign"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="$emit('edit-design')"
|
||||
>
|
||||
@@ -325,18 +306,18 @@
|
||||
Edit Design
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Camera Notes</Label>
|
||||
<p class="text-sm mt-1 text-muted-foreground">No camera notes</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Lighting Notes</Label>
|
||||
<p class="text-sm mt-1 text-muted-foreground">No lighting notes</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label class="text-xs text-muted-foreground">Animation Notes</Label>
|
||||
<p class="text-sm mt-1 text-muted-foreground">No animation notes</p>
|
||||
@@ -351,13 +332,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle, RefreshCw, ListTodo, Plus, MessageSquare, Package, Image, X, Edit, Send
|
||||
ListTodo, Plus, MessageSquare, Package, Image, Edit, Send
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import DetailPanelLoading from '@/components/shared/DetailPanelLoading.vue'
|
||||
import DetailPanelError from '@/components/shared/DetailPanelError.vue'
|
||||
import DetailPanelHeader from '@/components/shared/DetailPanelHeader.vue'
|
||||
|
||||
import { shotService, ShotStatus, type Shot, type TaskStatusInfo, TaskStatus } from '@/services/shot'
|
||||
import { taskService } from '@/services/task'
|
||||
@@ -422,14 +406,14 @@ const taskStatusCounts = computed(() => {
|
||||
approved: 0,
|
||||
retake: 0
|
||||
}
|
||||
|
||||
|
||||
tasks.value.forEach(task => {
|
||||
const statusKey = task.status.toString() // Convert enum to string
|
||||
if (counts.hasOwnProperty(statusKey)) {
|
||||
counts[statusKey as keyof typeof counts]++
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return counts
|
||||
})
|
||||
|
||||
@@ -507,19 +491,19 @@ const handleAddTask = async (taskType: string) => {
|
||||
}
|
||||
|
||||
const formatStatus = (status: ShotStatus) => {
|
||||
return status.split('_').map(word =>
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
const formatTaskStatus = (status: string) => {
|
||||
return status.split('_').map(word =>
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
const formatTaskType = (taskType: string) => {
|
||||
return taskType.split('_').map(word =>
|
||||
return taskType.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
@@ -589,7 +573,7 @@ const formatDeletedDate = (deletedAt: string) => {
|
||||
const date = new Date(deletedAt)
|
||||
const now = new Date()
|
||||
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60))
|
||||
|
||||
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours}h ago`
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user