From eb5587eb40bed8970ce5c1c96b9e8dfdb6e77201 Mon Sep 17 00:00:00 2001 From: indigo Date: Sat, 18 Jul 2026 16:07:39 +0800 Subject: [PATCH] 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 --- backend/routers/settings.py | 2 +- backend/schemas/task.py | 2 +- .../components/task/EditableTaskStatus.vue | 2 +- frontend/src/components/task/TaskBrowser.vue | 19 ++ .../components/task/TaskBulkActionsMenu.vue | 224 +++++++++++++----- .../src/components/task/TasksDataTable.vue | 13 +- frontend/src/components/task/columns.ts | 29 ++- frontend/src/views/TasksView.vue | 10 + 8 files changed, 232 insertions(+), 69 deletions(-) diff --git a/backend/routers/settings.py b/backend/routers/settings.py index 306b1aa..ec47b91 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -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 diff --git a/backend/schemas/task.py b/backend/schemas/task.py index 3ce06d4..7bbde44 100644 --- a/backend/schemas/task.py +++ b/backend/schemas/task.py @@ -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): diff --git a/frontend/src/components/task/EditableTaskStatus.vue b/frontend/src/components/task/EditableTaskStatus.vue index 6ffa56b..8cbe834 100644 --- a/frontend/src/components/task/EditableTaskStatus.vue +++ b/frontend/src/components/task/EditableTaskStatus.vue @@ -17,7 +17,7 @@ - + () 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() } ) diff --git a/frontend/src/components/task/TaskBulkActionsMenu.vue b/frontend/src/components/task/TaskBulkActionsMenu.vue index 373bdb0..6f82aa4 100644 --- a/frontend/src/components/task/TaskBulkActionsMenu.vue +++ b/frontend/src/components/task/TaskBulkActionsMenu.vue @@ -1,5 +1,9 @@ @@ -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() // 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) { diff --git a/frontend/src/components/task/TasksDataTable.vue b/frontend/src/components/task/TasksDataTable.vue index 1a60c06..4c9563f 100644 --- a/frontend/src/components/task/TasksDataTable.vue +++ b/frontend/src/components/task/TasksDataTable.vue @@ -1,6 +1,6 @@