Consistent status colors, prevent native context menu leaks, fix settings 404

- Task status badges (bulk change-status popover, right-click menu) now
  use the real per-project TaskStatusesStore colors/labels instead of the
  hardcoded TaskStatus enum, matching EditableTaskStatus everywhere.
- Suppress the native browser context menu from leaking through gaps in
  the task table (header/empty space), open popovers/dropdowns and their
  submenus, the status Select, and a second right-click at the same
  coordinates (the popover's positioning anchor now has
  pointer-events: none so it doesn't intercept the repeat click).
- Fix 404s on /api/settings/* caused by a double-declared route prefix.
- Fix 422 on bulk-applying a custom task status by relaxing
  BulkStatusUpdate.status from the TaskStatus enum to str, matching the
  existing single-task update schema.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 16:07:39 +08:00
parent f013e1cf25
commit eb5587eb40
8 changed files with 232 additions and 69 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ from schemas.global_settings import (
from utils.auth import get_current_user, require_admin_permission
from models.user import User
router = APIRouter(prefix="/settings", tags=["settings"])
router = APIRouter(tags=["settings"])
# Default upload limit in MB (1GB)
DEFAULT_UPLOAD_LIMIT_MB = 1000
+1 -1
View File
@@ -214,7 +214,7 @@ class ReviewResponse(ReviewBase):
# Bulk action schemas
class BulkStatusUpdate(BaseModel):
task_ids: List[int] = Field(..., min_length=1)
status: TaskStatus
status: str # Changed from TaskStatus enum to str to support custom statuses
class BulkAssignment(BaseModel):
@@ -17,7 +17,7 @@
<!-- <TaskStatusBadge :status="currentStatusObject"/> -->
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent @contextmenu.prevent>
<!-- System Statuses -->
<SelectItem
v-for="statusOption in allStatusOptions"
@@ -100,6 +100,7 @@ import { projectService, type ProjectMember } from '@/services/project'
import { shotService, type Shot } from '@/services/shot'
import { assetService, type Asset } from '@/services/asset'
import { useAuthStore } from '@/stores/auth'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel'
interface Props {
@@ -110,6 +111,7 @@ const props = defineProps<Props>()
const { toast } = useToast()
const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
// Detail panel composable
const {
@@ -462,12 +464,28 @@ const loadColumnVisibility = () => {
}
}
const loadTaskStatuses = async () => {
if (!props.projectId) return
try {
await taskStatusesStore.fetchProjectStatuses(props.projectId)
} catch (err) {
console.error('Failed to load task statuses:', err)
toast({
title: 'Failed to load task statuses',
description: err instanceof Error ? err.message : 'Task status options may be unavailable',
variant: 'destructive'
})
}
}
// Lifecycle
onMounted(() => {
loadColumnVisibility()
fetchTasks()
fetchEpisodes()
fetchProjectMembers()
loadTaskStatuses()
})
onUnmounted(() => {
@@ -482,6 +500,7 @@ watch(
fetchTasks()
fetchEpisodes()
fetchProjectMembers()
loadTaskStatuses()
}
)
@@ -1,5 +1,9 @@
<template>
<Popover v-model:open="isOpen">
<!-- pointer-events: none this is a positioning reference only. Without it, it's a
real (if invisible) 1x1px element sitting exactly at the original right-click
point, so a second right-click at the same spot hits *it* directly instead of
passing through to the row (or anything else) underneath. -->
<PopoverAnchor
:style="{
position: 'fixed',
@@ -7,6 +11,7 @@
top: `${props.position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}"
/>
<PopoverContent
@@ -15,6 +20,10 @@
:align="'start'"
@interact-outside="handleInteractOutside"
>
<!-- Plain native div, not a component prop-forward chain — guarantees the
listener actually reaches the DOM to suppress the browser's own menu
when right-clicking anywhere on this (already-a-right-click-triggered) menu. -->
<div @contextmenu.prevent>
<!-- Header showing selection count -->
<div class="px-2 py-1.5 text-sm font-semibold text-muted-foreground border-b mb-1">
{{ selectedCount }} {{ selectedCount === 1 ? 'task' : 'tasks' }} selected
@@ -37,51 +46,43 @@
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-48" side="right" align="start">
<!-- Loading state -->
<div v-if="isLoadingStatuses" class="px-2 py-1.5 text-sm text-muted-foreground">
Loading statuses...
<div @contextmenu.prevent>
<!-- Loading state -->
<div v-if="isLoadingStatuses" class="px-2 py-1.5 text-sm text-muted-foreground">
Loading statuses...
</div>
<!-- System statuses -->
<template v-else>
<div v-if="systemStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
System Statuses
</div>
<DropdownMenuItem
v-for="status in systemStatuses"
:key="status.id"
:disabled="isProcessing"
@click="handleStatusSelected(status.id)"
>
<TaskStatusBadge :status="status" compact />
</DropdownMenuItem>
<!-- Divider if both system and custom statuses exist -->
<div v-if="systemStatuses.length > 0 && customStatuses.length > 0" class="h-px bg-border my-1" />
<!-- Custom statuses -->
<div v-if="customStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
Custom Statuses
</div>
<DropdownMenuItem
v-for="status in customStatuses"
:key="status.id"
:disabled="isProcessing"
@click="handleStatusSelected(status.id)"
>
<TaskStatusBadge :status="status" compact />
</DropdownMenuItem>
</template>
</div>
<!-- System statuses -->
<template v-else>
<div v-if="systemStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
System Statuses
</div>
<DropdownMenuItem
v-for="status in systemStatuses"
:key="status.id"
:disabled="isProcessing"
@click="handleStatusSelected(status.id)"
class="flex items-center gap-2"
>
<div
class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: status.color }"
/>
<span>{{ status.name }}</span>
</DropdownMenuItem>
<!-- Divider if both system and custom statuses exist -->
<div v-if="systemStatuses.length > 0 && customStatuses.length > 0" class="h-px bg-border my-1" />
<!-- Custom statuses -->
<div v-if="customStatuses.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
Custom Statuses
</div>
<DropdownMenuItem
v-for="status in customStatuses"
:key="status.id"
:disabled="isProcessing"
@click="handleStatusSelected(status.id)"
class="flex items-center gap-2"
>
<div
class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: status.color }"
/>
<span>{{ status.name }}</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
@@ -89,7 +90,7 @@
<div class="h-px bg-border my-1" />
<!-- Assign To submenu -->
<DropdownMenu>
<DropdownMenu @update:open="onAssignMenuOpenChange">
<DropdownMenuTrigger as-child>
<button
:disabled="isProcessing || hasMultipleProjects"
@@ -99,20 +100,73 @@
<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">
No members available
<DropdownMenuContent class="w-56 p-0" side="right" align="start">
<div @contextmenu.prevent>
<div class="p-1.5 border-b" @keydown.stop @click.stop>
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
v-model="assigneeSearchQuery"
placeholder="Search members..."
class="h-7 pl-7 text-xs"
/>
</div>
</div>
<div class="max-h-56 overflow-y-auto p-1">
<div v-if="projectMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground">
No members available
</div>
<div v-else-if="filteredMembers.length === 0" class="px-2 py-1.5 text-sm text-muted-foreground">
No matching members
</div>
<template v-else>
<div v-if="recommendedMembers.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
Recommended
</div>
<DropdownMenuItem
v-for="member in recommendedMembers"
:key="member.user_id"
:disabled="isProcessing"
@click="handleAssigneeSelected(member.user_id)"
class="flex items-center gap-2"
>
<Avatar class="h-6 w-6 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getMemberInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col min-w-0">
<span class="truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span v-if="member.department_role" class="text-[10px] text-muted-foreground capitalize">{{ member.department_role }}</span>
</div>
</DropdownMenuItem>
<div v-if="recommendedMembers.length > 0 && otherMembers.length > 0" class="h-px bg-border my-1" />
<div v-if="otherMembers.length > 0" class="px-2 py-1 text-xs font-semibold text-muted-foreground">
{{ recommendedMembers.length > 0 ? 'All Members' : 'Members' }}
</div>
<DropdownMenuItem
v-for="member in otherMembers"
:key="member.user_id"
:disabled="isProcessing"
@click="handleAssigneeSelected(member.user_id)"
class="flex items-center gap-2"
>
<Avatar class="h-6 w-6 flex-shrink-0">
<AvatarImage :src="getAvatarUrl(member.user_avatar_url, member.user_first_name, member.user_last_name)" />
<AvatarFallback class="text-[9px]">{{ getMemberInitials(member) }}</AvatarFallback>
</Avatar>
<div class="flex flex-col min-w-0">
<span class="truncate">{{ member.user_first_name }} {{ member.user_last_name }}</span>
<span v-if="member.department_role" class="text-[10px] text-muted-foreground capitalize">{{ member.department_role }}</span>
</div>
</DropdownMenuItem>
</template>
</div>
</div>
<DropdownMenuItem
v-for="member in projectMembers"
:key="member.user_id"
:disabled="isProcessing"
@click="handleAssigneeSelected(member.user_id)"
>
{{ member.user_first_name }} {{ member.user_last_name }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</PopoverContent>
</Popover>
</template>
@@ -130,11 +184,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ChevronRight } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Input } from '@/components/ui/input'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { ChevronRight, Search } from 'lucide-vue-next'
import type { ProjectMember } from '@/services/project'
import type { Task } from '@/services/task'
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useAvatarUrl } from '@/composables/useAvatarUrl'
interface Props {
open: boolean
@@ -159,10 +217,14 @@ const emit = defineEmits<Emits>()
// Use the shared task statuses store
const taskStatusesStore = useTaskStatusesStore()
const { getAvatarUrl } = useAvatarUrl()
// Local state for menu open/close
const isOpen = ref(props.open)
// Assign To search state
const assigneeSearchQuery = ref('')
// Computed properties
const hasMultipleProjects = computed(() => {
if (props.selectedTasks.length === 0) return false
@@ -193,6 +255,54 @@ const customStatuses = computed(() => {
return statuses?.statuses || []
})
// Task types among the selected tasks, used to recommend project members whose
// department matches. "composite" (department) vs. "compositing" (task type) is
// the one known vocabulary mismatch, handled explicitly below.
const selectedTaskTypes = computed(() => new Set(props.selectedTasks.map(task => task.task_type)))
const departmentMatchesSelection = (departmentRole: string | undefined | null): boolean => {
if (!departmentRole) return false
for (const taskType of selectedTaskTypes.value) {
if (departmentRole === taskType) return true
if (departmentRole === 'composite' && taskType === 'compositing') return true
}
return false
}
const filteredMembers = computed(() => {
const query = assigneeSearchQuery.value.toLowerCase().trim()
if (!query) return props.projectMembers
return props.projectMembers.filter(member => {
const fullName = `${member.user_first_name} ${member.user_last_name}`.toLowerCase()
const department = member.department_role?.toLowerCase() || ''
return fullName.includes(query) || department.includes(query)
})
})
// Members whose department matches the selected task(s) are surfaced first, but
// everyone remains selectable — department_role is often unset, and a hard filter
// would leave the list empty for many projects.
const recommendedMembers = computed(() =>
filteredMembers.value.filter(member => departmentMatchesSelection(member.department_role))
)
const otherMembers = computed(() =>
filteredMembers.value.filter(member => !departmentMatchesSelection(member.department_role))
)
const getMemberInitials = (member: ProjectMember): string => {
const first = member.user_first_name?.charAt(0) || ''
const last = member.user_last_name?.charAt(0) || ''
return (first + last).toUpperCase()
}
const onAssignMenuOpenChange = (open: boolean) => {
if (!open) {
assigneeSearchQuery.value = ''
}
}
// Methods
const fetchStatuses = async () => {
if (!currentProjectId.value || hasMultipleProjects.value) {
@@ -1,6 +1,6 @@
<template>
<div class="px-4 h-full flex flex-col">
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
<div class="rounded-md border flex-1 min-h-0 overflow-hidden" @contextmenu.prevent>
<Table>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
@@ -88,6 +88,7 @@ import {
import { createColumns } from './columns'
import { type Task } from '@/services/task'
import { TaskStatus } from '@/services/asset'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
// Props interface
interface Props {
@@ -112,6 +113,8 @@ interface Emits {
const emit = defineEmits<Emits>()
const taskStatusesStore = useTaskStatusesStore()
// Internal state
const sorting = ref<SortingState>([{ id: 'created_at', desc: true }])
const rowSelection = ref<RowSelectionState>({})
@@ -141,6 +144,14 @@ const columns = createColumns({
// row-double-click actually opens the panel, so reuse that instead.
onViewDetails: (task: Task) => emit('row-double-click', task),
onReassign: (task: Task) => emit('row-double-click', task),
// Real per-project statuses (system + custom), matching EditableTaskStatus's cell
// rendering. Scoped to the selected rows' project (falls back to props.projectId,
// which is 0/unset for cross-project lists like My Tasks with no selection yet).
getAllStatusOptions: () => {
const selected = getSelectedTasks()
const projectId = selected.length > 0 ? selected[0].project_id : props.projectId
return projectId ? taskStatusesStore.getAllStatusOptions(projectId) : []
},
})
// TanStack Table configuration
+21 -8
View File
@@ -15,10 +15,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import TaskStatusBadge from '@/components/asset/TaskStatusBadge.vue'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import EditableTaskStatus from '@/components/task/EditableTaskStatus.vue'
import { type Task } from '@/services/task'
import { TaskStatus } from '@/services/asset'
import type { CustomTaskStatus, SystemTaskStatus } from '@/services/customTaskStatus'
function formatDate(dateString: string): string {
const date = new Date(dateString)
@@ -35,6 +36,10 @@ interface ColumnCallbacks {
getSelectedCount?: () => number
onViewDetails?: (task: Task) => void
onReassign?: (task: Task) => void
// Real per-project status options (system + custom), matching what EditableTaskStatus
// shows in the cell — falls back to [] if the relevant project's statuses aren't
// loaded yet.
getAllStatusOptions?: () => Array<CustomTaskStatus | SystemTaskStatus>
}
export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] => {
@@ -150,10 +155,14 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
}),
h(PopoverContent, { class: 'w-48 p-2', align: 'start' }, {
default: () => {
return h('div', { class: 'flex flex-col gap-1' }, [
const allStatusOptions = callbacks?.getAllStatusOptions?.() || []
// Plain native onContextmenu (not relying on the .prevent modifier
// reaching through PopoverContent's attrs-forwarding) so right-clicking
// this popover doesn't fall through to the browser's own menu.
return h('div', { class: 'flex flex-col gap-1', onContextmenu: (e: Event) => e.preventDefault() }, [
h('div', { class: 'px-2 py-1.5 text-sm font-semibold' }, `Change Status`),
...Object.values(TaskStatus).map((status) =>
...allStatusOptions.map((statusOption) =>
h(
Button,
{
@@ -161,11 +170,11 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
size: 'sm',
class: 'justify-start',
onClick: () => {
callbacks?.onBulkStatusChange?.(status)
callbacks?.onBulkStatusChange?.(statusOption.id as TaskStatus)
isPopoverOpen.value = false
},
},
() => h(TaskStatusBadge, { status, class: 'w-full' })
() => h(TaskStatusBadge, { status: statusOption, compact: true, class: 'w-full' })
)
),
])
@@ -352,7 +361,11 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
DropdownMenuContent,
{ align: 'end' },
{
default: () => [
// Native div wrapper (not a prop on DropdownMenuContent itself) — that
// component doesn't explicitly forward arbitrary attrs the way
// PopoverContent does, so a listener placed directly on it isn't
// guaranteed to reach the real DOM element.
default: () => h('div', { onContextmenu: (e: Event) => e.preventDefault() }, [
h(
DropdownMenuItem,
{
@@ -385,7 +398,7 @@ export const createColumns = (callbacks?: ColumnCallbacks): ColumnDef<Task>[] =>
],
}
),
],
]),
}
),
],
+10
View File
@@ -125,11 +125,13 @@ import { TaskStatus } from '@/services/asset'
import { episodeService, type Episode } from '@/services/episode'
import { projectService, type Project, type ProjectMember } from '@/services/project'
import { useAuthStore } from '@/stores/auth'
import { useTaskStatusesStore } from '@/stores/taskStatuses'
import { useDetailPanel } from '@/composables/useDetailPanel'
import { useToast } from '@/components/ui/toast/use-toast'
const { toast } = useToast()
const authStore = useAuthStore()
const taskStatusesStore = useTaskStatusesStore()
// Detail panel composable
const {
@@ -307,6 +309,14 @@ async function fetchProjectScopedFilters() {
episodes.value = []
projectMembers.value = []
}
// Ensures the "Change Status" table-header dropdown has real per-project status
// options ready as soon as a project is picked, not just once a row happens to render.
try {
await taskStatusesStore.fetchProjectStatuses(projectId)
} catch (error) {
console.error('Failed to fetch task statuses:', error)
}
}
const handleRowClick = () => {