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:
2026-07-18 02:15:55 +08:00
parent cd3628a255
commit 26807984ee
34 changed files with 714 additions and 1146 deletions
@@ -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>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ListFilter } 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 TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
interface Props {
allTaskTypes: string[]
projectId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'filter-changed': [filter: string]
}>()
const taskStatusesStore = useTaskStatusesStore()
const selectedFilters = ref<string[]>([])
// System status options (fallback if no project ID / statuses not loaded yet)
const defaultStatusOptions = [
{ id: 'not_started', name: 'Not Started', color: '', is_system: true },
{ id: 'in_progress', name: 'In Progress', color: '', is_system: true },
{ id: 'submitted', name: 'Submitted', color: '', is_system: true },
{ id: 'approved', name: 'Approved', color: '', is_system: true },
{ id: 'retake', name: 'Retake', color: '', is_system: true },
]
const allStatuses = computed(() => {
if (!props.projectId) return defaultStatusOptions
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
if (!statusData) return defaultStatusOptions
const systemStatusList = statusData.system_statuses.map(s => ({
id: s.id, name: s.name, color: s.color, is_system: s.is_system,
}))
const customStatusList = statusData.statuses.map(s => ({
id: s.id, name: s.name, color: s.color, is_system: false,
}))
return [...systemStatusList, ...customStatusList]
})
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)
}
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
emit('filter-changed', apiFilter)
}
const clearAllFilters = () => {
selectedFilters.value = []
emit('filter-changed', '')
}
const formatTaskType = (taskType: string) =>
taskType.charAt(0).toUpperCase() + taskType.slice(1)
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<ListFilter class="mr-2 h-4 w-4" />
Task Status
<Badge
v-if="selectedFilters.length > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ selectedFilters.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[220px] p-0" align="start">
<Command>
<CommandInput placeholder="Search task status..." />
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<!-- All Tasks Option -->
<CommandGroup>
<CheckableCommandItem
value="all"
:model-value="selectedFilters.length === 0"
@update:model-value="clearAllFilters"
>
<span>All Tasks</span>
</CheckableCommandItem>
</CommandGroup>
<!-- Task Type Groups -->
<CommandGroup v-for="taskType in allTaskTypes" :key="taskType">
<CommandSeparator />
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ formatTaskType(taskType) }}
</div>
<CheckableCommandItem
v-for="status in allStatuses"
:key="`${taskType}:${status.id}`"
:value="`${taskType}:${status.id}`"
:model-value="selectedFilters.includes(`${taskType}:${status.id}`)"
@update:model-value="toggleFilter(`${taskType}:${status.id}`)"
>
<div class="flex items-center gap-2">
<TaskStatusBadge :status="status as any" />
</div>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>