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:
@@ -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>
|
||||
Reference in New Issue
Block a user